From 63c218a0e9c595dbc1a00377c3a0ad598ef2273d Mon Sep 17 00:00:00 2001 From: Marisa Kirisame Date: Tue, 29 Sep 2026 17:36:33 +0100 Subject: [PATCH] Add extra couch features --- Couch/scripting/Couch.sp | 221 +++++++++++++++++++++++---- Couch/scripting/CouchAdmin.sp | 243 ++++++++++++++++++++++++++++++ Couch/scripting/CouchHud.sp | 62 ++++++-- Couch/scripting/include/Couch.inc | 35 ++++- 4 files changed, 516 insertions(+), 45 deletions(-) diff --git a/Couch/scripting/Couch.sp b/Couch/scripting/Couch.sp index b627e2a..3d67929 100644 --- a/Couch/scripting/Couch.sp +++ b/Couch/scripting/Couch.sp @@ -63,6 +63,7 @@ static const char g_sPistols[][] = ConVar g_cvModel; ConVar g_cvPushScale; ConVar g_cvPistolOnly; +ConVar g_cvAllowUse; ConVar g_cvEffect; ConVar g_cvEffectIntensity; ConVar g_cvIgnoreClip; @@ -70,11 +71,13 @@ ConVar g_cvCollisionGroup; // Optional native from the UNLOZE playtime plugin; nobody is refused if it is absent. // The plugin reports tier -1 until a player's hours have loaded. -ConVar g_cvTierRequirement; +ConVar g_cvMoveTier; +ConVar g_cvButtonTier; GlobalForward g_fwdOnCouchSpawned; GlobalForward g_fwdOnCouchRemoved; GlobalForward g_fwdOnCouchDamaged; +GlobalForward g_fwdOnCouchInteraction; GlobalForward g_fwdOnClientBlocked; int g_iCouchRef = INVALID_ENT_REFERENCE; @@ -90,7 +93,7 @@ int g_iBeaconTick; Handle g_hForceVPhysicsCollide; -// Couch bans: hits are refused in OnCouchTakeDamage, and the vphysics collision filter is +// Couch bans: hits and +use are refused in OnCouchTakeDamage/OnCouchUse, and the vphysics collision filter is // detoured so a blocked player can't push it. The detour is only enabled while someone is blocked. Handle g_hShouldCollideDetour; Handle g_hCollisionRulesChanged; @@ -123,6 +126,8 @@ public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max CreateNative("Couch_SetEffectEnabled", Native_SetEffectEnabled); CreateNative("Couch_IsPistolOnly", Native_IsPistolOnly); CreateNative("Couch_SetPistolOnly", Native_SetPistolOnly); + CreateNative("Couch_IsUseAllowed", Native_IsUseAllowed); + CreateNative("Couch_SetUseAllowed", Native_SetUseAllowed); CreateNative("Couch_SetClientBlocked", Native_SetClientBlocked); CreateNative("Couch_IsClientBlocked", Native_IsClientBlocked); RegPluginLibrary("Couch"); @@ -137,15 +142,18 @@ public void OnPluginStart() g_fwdOnCouchSpawned = new GlobalForward("Couch_OnCouchSpawned", ET_Ignore, Param_Cell); g_fwdOnCouchRemoved = new GlobalForward("Couch_OnCouchRemoved", ET_Ignore); g_fwdOnCouchDamaged = new GlobalForward("Couch_OnCouchDamaged", ET_Ignore, Param_Cell, Param_Cell, Param_Float); + g_fwdOnCouchInteraction = new GlobalForward("Couch_OnCouchInteraction", ET_Ignore, Param_Cell, Param_Cell, Param_Cell, Param_String); g_fwdOnClientBlocked = new GlobalForward("Couch_OnClientBlocked", ET_Ignore, Param_Cell, Param_Cell); g_cvModel = CreateConVar("sm_couch_model", COUCH_DEFAULT_MODEL, "Model used for the couch. Applies to the next spawn.", FCVAR_NOTIFY); g_cvPushScale = CreateConVar("sm_couch_push_scale", "1.0", "Multiplier on the force bullets apply to the couch. 1.0 is the engine's own push.", FCVAR_NOTIFY, true, 0.0, true, 100.0); g_cvPistolOnly = CreateConVar("sm_couch_pistol_only", "0", "Only pistols and knives move the couch; other guns and grenades are ignored.", FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvAllowUse = CreateConVar("sm_allow_couch_use", "1", "Players may +use the couch to pick it up. 0 means it can only be moved by shooting it.", FCVAR_NOTIFY, true, 0.0, true, 1.0); g_cvEffect = CreateConVar("sm_couch_effect", "0", "Colour cycle and pulsing light on the couch.", FCVAR_NOTIFY, true, 0.0, true, 1.0); g_cvEffectIntensity = CreateConVar("sm_couch_effect_intensity", "3", "Brightness of the couch effect light. 0 is off, 3 is the default, 8 is very bright. Applies immediately.", FCVAR_NOTIFY, true, 0.0, true, 10.0); g_cvIgnoreClip = CreateConVar("sm_couch_ignore_clip", "1", "Let the couch pass through func_clip_vphysics brushes.", FCVAR_NOTIFY, true, 0.0, true, 1.0); - g_cvTierRequirement = CreateConVar("sm_couch_tier_requirement", "0", "Minimum playtime tier needed to shoot the couch.", FCVAR_NOTIFY, true, 0.0); + g_cvMoveTier = CreateConVar("sm_couch_move_tier", "0", "Minimum playtime tier needed to shoot or +use the couch.", FCVAR_NOTIFY, true, 0.0); + g_cvButtonTier = CreateConVar("sm_couch_button_tier", "0", "Minimum playtime tier needed to press or shoot map buttons (func_button).", FCVAR_NOTIFY, true, 0.0); g_cvCollisionGroup = CreateConVar("sm_couch_collision_group", "5", "Collision group given to the couch at spawn. 5 (player), 0 (prop)", FCVAR_NOTIFY, true, 0.0, true, 20.0); g_cvModel.AddChangeHook(OnModelChanged); @@ -158,13 +166,17 @@ public void OnPluginStart() LoadGameData(); - // Late load: the clip brushes already exist, so hook them now. + // Late load: the clip brushes and buttons already exist, so hook them now. + int entity = -1; if (g_hForceVPhysicsCollide != null) { - int entity = -1; while ((entity = FindEntityByClassname(entity, "func_clip_vphysics")) != -1) DHookEntity(g_hForceVPhysicsCollide, false, entity); } + + entity = -1; + while ((entity = FindEntityByClassname(entity, "func_button")) != -1) + HookButton(entity); } public void OnPluginEnd() @@ -189,9 +201,11 @@ public void OnMapStart() public void OnEntityCreated(int entity, const char[] classname) { - // The cvar is checked in the callback so it can be flipped at runtime. + // The cvars are checked in the callbacks so they can be flipped at runtime. if (g_hForceVPhysicsCollide != null && StrEqual(classname, "func_clip_vphysics")) DHookEntity(g_hForceVPhysicsCollide, false, entity); + else if (StrEqual(classname, "func_button")) + HookButton(entity); } public void OnEntityDestroyed(int entity) @@ -415,8 +429,9 @@ int SpawnCouchAt(const float vecOrigin[3], const float vecAngles[3]) SetEntityFlags(couch, FL_CLIENT); SetEntProp(couch, Prop_Data, "m_iTeamNum", CS_TEAM_CT); - // SDKHooks drops the hook with the entity. + // SDKHooks drops the hooks with the entity. SDKHook(couch, SDKHook_OnTakeDamage, OnCouchTakeDamage); + SDKHook(couch, SDKHook_Use, OnCouchUse); // Takes the beacon's colour modulation at full opacity. SetEntityRenderMode(couch, RENDER_NORMAL); @@ -462,22 +477,8 @@ public Action OnCouchTakeDamage(int victim, int &attacker, int &inflictor, float { bool bFromClient = (attacker >= 1 && attacker <= MaxClients && IsClientInGame(attacker)); - // Couch-banned: the ban plugin gets a forward so it can tell them why. - if (bFromClient && g_bBlocked[attacker]) - { - Call_StartForward(g_fwdOnClientBlocked); - Call_PushCell(victim); - Call_PushCell(attacker); - Call_Finish(); - + if (bFromClient && IsClientRefused(victim, attacker)) return Plugin_Handled; - } - - if (bFromClient && !MeetsTierRequirement(attacker)) - { - PrintHintText(attacker, "You need to be tier %d or higher to shoot the couch.", g_cvTierRequirement.IntValue); - return Plugin_Handled; - } if (bFromClient && g_cvPistolOnly.BoolValue && !IsPistolOrKnifeHit(attacker, weapon, damagetype)) return Plugin_Handled; @@ -498,15 +499,103 @@ public Action OnCouchTakeDamage(int victim, int &attacker, int &inflictor, float Call_PushCell(attacker); Call_PushFloat(damage); Call_Finish(); + + ReportHit(victim, attacker, inflictor, weapon, damagetype); } return result; } -// Without the playtime plugin nobody is refused. -bool MeetsTierRequirement(int client) +// +use on the couch: same ban and tier rules as shooting it, and off entirely with sm_allow_couch_use 0. +// Plugin_Handled stops the pickup. +public Action OnCouchUse(int entity, int activator, int caller, UseType type, float value) +{ + if (activator < 1 || activator > MaxClients || !IsClientInGame(activator)) + return Plugin_Continue; + + // Ban and tier first; their message is the more useful one. + if (IsClientRefused(entity, activator)) + return Plugin_Handled; + + if (!g_cvAllowUse.BoolValue) + { + PrintHintText(activator, "The couch can't be picked up. Shoot it to move it."); + return Plugin_Handled; + } + + ReportInteraction(entity, activator, CouchMethod_Use, ""); + return Plugin_Continue; +} + +// Works out how an accepted hit moved the couch. Skipped when nobody listens; it runs per pellet. +void ReportHit(int couch, int attacker, int inflictor, int weapon, int damagetype) +{ + if (g_fwdOnCouchInteraction.FunctionCount == 0) + return; + + char sWeapon[64]; + CouchMethod method = CouchMethod_Other; + + if (damagetype & (DMG_SLASH | DMG_CLUB)) + { + strcopy(sWeapon, sizeof(sWeapon), "knife"); + method = CouchMethod_Knife; + } + else if (damagetype & DMG_BULLET) + { + GetBulletWeapon(attacker, weapon, sWeapon, sizeof(sWeapon)); + ReplaceStringEx(sWeapon, sizeof(sWeapon), "weapon_", ""); + method = StrEqual(sWeapon, "knife") ? CouchMethod_Knife : CouchMethod_Bullet; + } + else if (inflictor > MaxClients && IsValidEntity(inflictor)) + { + // Blast from anything but a grenade (env_explosion and the like) stays Other. + GetEntityClassname(inflictor, sWeapon, sizeof(sWeapon)); + if ((damagetype & DMG_BLAST) && StrEqual(sWeapon, "hegrenade_projectile")) + { + strcopy(sWeapon, sizeof(sWeapon), "hegrenade"); + method = CouchMethod_Grenade; + } + } + + ReportInteraction(couch, attacker, method, sWeapon); +} + +void ReportInteraction(int couch, int client, CouchMethod method, const char[] sWeapon) +{ + Call_StartForward(g_fwdOnCouchInteraction); + Call_PushCell(couch); + Call_PushCell(client); + Call_PushCell(method); + Call_PushString(sWeapon); + Call_Finish(); +} + +// Couch ban or tier requirement. Tells the client why, or for a ban lets the ban plugin do so. +bool IsClientRefused(int couch, int client) +{ + if (g_bBlocked[client]) + { + Call_StartForward(g_fwdOnClientBlocked); + Call_PushCell(couch); + Call_PushCell(client); + Call_Finish(); + + return true; + } + + if (!MeetsTier(client, g_cvMoveTier.IntValue)) + { + PrintHintText(client, "You need to be tier %d or higher to move the couch.", g_cvMoveTier.IntValue); + return true; + } + + return false; +} + +// Without the playtime plugin nobody is refused. +bool MeetsTier(int client, int required) { - int required = g_cvTierRequirement.IntValue; if (required <= 0) return true; @@ -526,15 +615,9 @@ bool IsPistolOrKnifeHit(int attacker, int weapon, int damagetype) if (!(damagetype & DMG_BULLET)) return false; - // CS:S rarely names the weapon in bullet damage, so fall back to the active weapon. - if (weapon <= MaxClients || !IsValidEntity(weapon)) - weapon = GetEntPropEnt(attacker, Prop_Send, "m_hActiveWeapon"); - - if (weapon == -1) - return false; - char sClass[32]; - GetEntityClassname(weapon, sClass, sizeof(sClass)); + if (!GetBulletWeapon(attacker, weapon, sClass, sizeof(sClass))) + return false; // In case a knife hit ever arrives typed as a bullet. if (StrEqual(sClass, "weapon_knife")) @@ -549,6 +632,67 @@ bool IsPistolOrKnifeHit(int attacker, int weapon, int damagetype) return false; } +// Classname of the weapon behind a bullet hit. CS:S rarely names it in bullet damage, so this +// falls back to the active weapon. Returns false, with an empty buffer, if there is none. +bool GetBulletWeapon(int attacker, int weapon, char[] buffer, int maxlen) +{ + if (weapon <= MaxClients || !IsValidEntity(weapon)) + weapon = GetEntPropEnt(attacker, Prop_Send, "m_hActiveWeapon"); + + if (weapon == -1) + { + buffer[0] = '\0'; + return false; + } + + GetEntityClassname(weapon, buffer, maxlen); + return true; +} + +// --------------------------------------------------------------------------------------------- +// Buttons +// --------------------------------------------------------------------------------------------- + +// func_button spawnflag: shooting the button presses it. +#define SF_BUTTON_DAMAGE_ACTIVATES 512 + +void HookButton(int button) +{ + SDKHook(button, SDKHook_Use, OnButtonUse); + SDKHook(button, SDKHook_OnTakeDamage, OnButtonTakeDamage); +} + +// A player's own +use has them as both activator and caller. A Press input relayed from map logic +// has the firing entity as caller, so !activator chains through triggers still work. +public Action OnButtonUse(int entity, int activator, int caller, UseType type, float value) +{ + if (activator < 1 || activator > MaxClients || activator != caller || !IsClientInGame(activator)) + return Plugin_Continue; + + return IsClientRefusedButton(activator) ? Plugin_Handled : Plugin_Continue; +} + +// Only buttons that shooting presses; hits on any other func_button are left alone. +public Action OnButtonTakeDamage(int victim, int &attacker, int &inflictor, float &damage, int &damagetype, int &weapon, float damageForce[3], float damagePosition[3], int damagecustom) +{ + if (attacker < 1 || attacker > MaxClients || !IsClientInGame(attacker)) + return Plugin_Continue; + + if (!(GetEntProp(victim, Prop_Data, "m_spawnflags") & SF_BUTTON_DAMAGE_ACTIVATES)) + return Plugin_Continue; + + return IsClientRefusedButton(attacker) ? Plugin_Handled : Plugin_Continue; +} + +bool IsClientRefusedButton(int client) +{ + if (MeetsTier(client, g_cvButtonTier.IntValue)) + return false; + + PrintHintText(client, "You need to be tier %d or higher to press buttons.", g_cvButtonTier.IntValue); + return true; +} + // --------------------------------------------------------------------------------------------- // func_clip_vphysics // --------------------------------------------------------------------------------------------- @@ -962,6 +1106,17 @@ public any Native_SetPistolOnly(Handle plugin, int numParams) return 0; } +public any Native_IsUseAllowed(Handle plugin, int numParams) +{ + return g_cvAllowUse.BoolValue; +} + +public any Native_SetUseAllowed(Handle plugin, int numParams) +{ + g_cvAllowUse.SetBool(view_as(GetNativeCell(1))); + return 0; +} + public any Native_SetClientBlocked(Handle plugin, int numParams) { int client = GetNativeCell(1); diff --git a/Couch/scripting/CouchAdmin.sp b/Couch/scripting/CouchAdmin.sp index 04c1bb7..6d3d46f 100644 --- a/Couch/scripting/CouchAdmin.sp +++ b/Couch/scripting/CouchAdmin.sp @@ -6,9 +6,20 @@ #define PREFIX "\x01[SM] \x04" +// A player who keeps moving the couch the same way is reported at most once per this many seconds. +#define LOG_REPEAT_INTERVAL 2.0 + // So commands fail with a message instead of an unbound-native error while the core is unloaded. bool g_bCoreLoaded; +// Admins who asked to see couch interactions. Per slot, so it survives map changes. +bool g_bLogging[MAXPLAYERS + 1]; + +// Per player: what was last reported, and when the same thing may be reported again. +CouchMethod g_iLastMethod[MAXPLAYERS + 1]; +char g_sLastWeapon[MAXPLAYERS + 1][64]; +float g_fNextReport[MAXPLAYERS + 1]; + public Plugin myinfo = { name = "Couch Admin", @@ -26,6 +37,20 @@ public void OnPluginStart() RegAdminCmd("sm_gotocouch", Command_GotoCouch, ADMFLAG_CHANGEMAP, "Teleports you to the couch"); RegAdminCmd("sm_removecouch", Command_RemoveCouch, ADMFLAG_CHANGEMAP, "Removes the couch"); RegAdminCmd("sm_couchpistolonly", Command_PistolOnly, ADMFLAG_CHANGEMAP, "sm_couchpistolonly [0|1] - only pistols and knives move the couch; no argument toggles"); + RegAdminCmd("sm_couchlog", Command_Log, ADMFLAG_CHANGEMAP, "sm_couchlog [0|1] - show who moves the couch and how in your console, grenades in chat too; no argument toggles"); +} + +// Game time restarts with the map. +public void OnMapStart() +{ + for (int i = 1; i <= MAXPLAYERS; i++) + g_fNextReport[i] = 0.0; +} + +public void OnClientDisconnect(int client) +{ + g_bLogging[client] = false; + g_fNextReport[client] = 0.0; } public void OnAllPluginsLoaded() @@ -108,6 +133,19 @@ public Action Command_PistolOnly(int client, int args) return Plugin_Handled; } +public Action Command_Log(int client, int args) +{ + if (!RequireInGame(client)) + return Plugin_Handled; + + bool bEnable = !g_bLogging[client]; + if (args >= 1) + bEnable = (GetCmdArgInt(1) != 0); + + DoSetLogging(client, bEnable); + return Plugin_Handled; +} + bool RequireInGame(int client) { if (client >= 1 && IsClientInGame(client)) @@ -202,6 +240,124 @@ void DoSetPistolOnly(int client, bool bEnable) LogAction(client, -1, "\"%L\" turned couch pistol-only mode %s.", client, bEnable ? "on" : "off"); } +// Announced to everyone so players know to shoot the couch instead. +void DoToggleUse(int client) +{ + bool bAllow = !Couch_IsUseAllowed(); + Couch_SetUseAllowed(bAllow); + + if (bAllow) + ShowActivity2(client, PREFIX, "\x01Allowed pushing the \x04couch\x01 with USE."); + else + ShowActivity2(client, PREFIX, "\x01Disabled pushing the \x04couch\x01 with USE, it must be shot to move."); + + LogAction(client, -1, "\"%L\" %s couch use.", client, bAllow ? "allowed" : "disallowed"); +} + +// The tier cvars belong to the core plugin, so they are looked up by name. sName is "move" or "button". +void DoChangeTier(int client, const char[] sName, int iDelta) +{ + ConVar cvar = FindTierConVar(sName); + if (cvar == null) + return; + + int iTier = cvar.IntValue + iDelta; + if (iTier < 0) + iTier = 0; + + cvar.IntValue = iTier; + + if (iTier == 0) + { + ShowActivity2(client, PREFIX, "\x01Turned off the couch \x04%s\x01 tier requirement.", sName); + LogAction(client, -1, "\"%L\" turned off the couch %s tier requirement.", client, sName); + } + else + { + ShowActivity2(client, PREFIX, "\x01Set the couch \x04%s\x01 tier requirement to \x04%d\x01.", sName, iTier); + LogAction(client, -1, "\"%L\" set the couch %s tier requirement to %d.", client, sName, iTier); + } +} + +ConVar FindTierConVar(const char[] sName) +{ + char sCvar[32]; + FormatEx(sCvar, sizeof(sCvar), "sm_couch_%s_tier", sName); + return FindConVar(sCvar); +} + +// Only affects what this admin sees, so it is neither announced nor logged. +void DoSetLogging(int client, bool bEnable) +{ + g_bLogging[client] = bEnable; + + if (bEnable) + PrintToChat(client, PREFIX ... "\x01Couch log \x04on\x01, see your console for who moves the couch and how. Grenades also show here."); + else + PrintToChat(client, PREFIX ... "\x01Couch log \x04off\x01."); +} + +// --------------------------------------------------------------------------------------------- +// Couch log +// --------------------------------------------------------------------------------------------- + +public void Couch_OnCouchInteraction(int couch, int client, CouchMethod method, const char[] weapon) +{ + if (!AnyoneLogging()) + return; + + // Held fire and shotgun pellets would flood the console; a change of weapon or method is always shown. + float fNow = GetGameTime(); + if (fNow < g_fNextReport[client] && method == g_iLastMethod[client] && StrEqual(weapon, g_sLastWeapon[client])) + return; + + g_iLastMethod[client] = method; + strcopy(g_sLastWeapon[client], sizeof(g_sLastWeapon[]), weapon); + g_fNextReport[client] = fNow + LOG_REPEAT_INTERVAL; + + // Plain text: the console doesn't render chat colour codes. + char sHow[96]; + switch (method) + { + case CouchMethod_Bullet: + { + if (weapon[0] != '\0') + FormatEx(sHow, sizeof(sHow), "shot the couch with %s", weapon); + else + strcopy(sHow, sizeof(sHow), "shot the couch"); + } + case CouchMethod_Knife: strcopy(sHow, sizeof(sHow), "knifed the couch"); + case CouchMethod_Grenade: strcopy(sHow, sizeof(sHow), "hit the couch with a grenade"); + case CouchMethod_Use: strcopy(sHow, sizeof(sHow), "pushed the couch with USE"); + default: FormatEx(sHow, sizeof(sHow), "moved the couch with %s", weapon[0] != '\0' ? weapon : "something"); + } + + // Everything goes to the console; grenades are rare and knock the couch far, so they go to chat too. + // The userid is there so it can go straight into sm_couchban. + int userid = GetClientUserId(client); + for (int i = 1; i <= MaxClients; i++) + { + if (!g_bLogging[i] || !IsClientInGame(i)) + continue; + + PrintToConsole(i, "[Couch] %N (#%d) %s", client, userid, sHow); + + if (method == CouchMethod_Grenade) + PrintToChat(i, "\x01[Couch] \x04%N\x01 (#%d) hit the couch with a \x04grenade", client, userid); + } +} + +bool AnyoneLogging() +{ + for (int i = 1; i <= MaxClients; i++) + { + if (g_bLogging[i]) + return true; + } + + return false; +} + // --------------------------------------------------------------------------------------------- // Menu // --------------------------------------------------------------------------------------------- @@ -234,6 +390,14 @@ void ShowCouchMenu(int client) FormatEx(sItem, sizeof(sItem), "Pistol only: %s", Couch_IsPistolOnly() ? "ON" : "OFF"); menu.AddItem("pistol", sItem); + FormatEx(sItem, sizeof(sItem), "Allow USE: %s", Couch_IsUseAllowed() ? "ON" : "OFF"); + menu.AddItem("use", sItem); + + FormatEx(sItem, sizeof(sItem), "Log to my console: %s", g_bLogging[client] ? "ON" : "OFF"); + menu.AddItem("log", sItem); + + menu.AddItem("tiers", "Tier requirements"); + menu.ExitButton = true; menu.Display(client, MENU_TIME_FOREVER); } @@ -267,6 +431,15 @@ public int MenuHandler_Couch(Menu menu, MenuAction action, int param1, int param DoToggleEffect(client); else if (StrEqual(sInfo, "pistol")) DoSetPistolOnly(client, !Couch_IsPistolOnly()); + else if (StrEqual(sInfo, "use")) + DoToggleUse(client); + else if (StrEqual(sInfo, "log")) + DoSetLogging(client, !g_bLogging[client]); + else if (StrEqual(sInfo, "tiers")) + { + ShowTierMenu(client); + return 0; + } ShowCouchMenu(client); } @@ -279,3 +452,73 @@ public int MenuHandler_Couch(Menu menu, MenuAction action, int param1, int param return 0; } + +void ShowTierMenu(int client) +{ + Menu menu = new Menu(MenuHandler_Tier); + menu.SetTitle("Couch - Tier requirements"); + + AddTierItems(menu, "move", "Move"); + AddTierItems(menu, "button", "Button"); + + menu.ExitBackButton = true; + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +// Adds "