diff --git a/README.md b/README.md index a94135f..fc71d83 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,25 @@ # ArcaneGameDUELS Core -Система дуэлей 1v1 для CS:GO серверов на SourceMod. Поддерживает арену, зону ограничения, выбор оружия, модификаторы и интеграцию с системой XP (lvl_ranks). +Система дуэлей 1v1 для CS:GO серверов на SourceMod. Поддерживает арену, зону ограничения, выбор оружия, модификаторы, статистику и интеграцию с системой XP (lvl_ranks). ## Функции -- Дуэли **1v1** между любыми двумя игроками -- Автоматический запуск дуэли при обнаружении двух игроков на сервере +- Дуэли **1v1** между любыми двумя игроками (автозапуск при 1v1 на сервере с >2 игроками) - **Арена**: фиксированные точки спавна из конфига `ArcaneGameDUELS_Arena.cfg` - **Зона ограничения**: дуэлянты не могут покинуть арену (настраиваемый padding) -- Выбор оружия: Deagle, AK-47, M4A4, M4A1-S, AWP, Scout, Knife -- Модификаторы: обычный / NoZoom / Headshot Only -- Сохранение и восстановление инвентаря и здоровья после дуэли +- Выбор оружия: Knife / Deagle / AK-47 / M4A4 / M4A1-S / AWP / Scout + **случайное оружие** +- Модификаторы: обычный / Headshot Only +- **Полное сохранение и восстановление инвентаря** после дуэли (оружие, патроны в магазине и резерве, гранаты, броня, шлем, дефузер, деньги) - Таймер дуэли с ограничением по времени -- Интеграция XP через **lvl_ranks** (награда за победу) -- **Forwards** для других плагинов: `OnDuelStarted`, `OnDuelFinished`, `OnDuelDraw` +- Интеграция XP через **lvl_ranks** (награда за победу + штраф за rage-quit) +- **Forwards** для других плагинов: `AGD_OnDuelStarted`, `AGD_OnDuelFinished`, `AGD_OnDuelDraw` - Beacon-индикатор (звук + частицы) вокруг дуэлянтов +- **Звуковые эффекты**: победителю — фанфара, проигравшему — death-камера +- **Файловый лог** всех дуэлей в `addons/sourcemod/logs/arcane_duels.log` +- **Статистика per-игрок** (wins / losses / draws / rage-quits / total XP) в файл `addons/sourcemod/data/arcane_duels_stats.cfg` +- Команды `!duelstats` и `!duel_top` — статистика и топ-10 игроков +- Админ-команда `!duel_forceend` для принудительной остановки дуэли +- ConVar `agd_duel_block_external` — другие плагины могут временно блокировать авто-дуэли (например, во время кастомного раунда) ## Зависимости @@ -31,52 +36,89 @@ ## Конфиг арены -Путь: `cfg/sourcemod/ArcaneGameDUELS_Arena.cfg` +Путь: `cfg/sourcemod/ArcaneGameDUELS_Arena.cfg` (ключ-значения через KeyValues, на каждую карту своя секция). -``` -// Позиция спавна игрока 1 -sm_duels_arena_spawn1_x "0.0" -sm_duels_arena_spawn1_y "0.0" -sm_duels_arena_spawn1_z "0.0" -sm_duels_arena_spawn1_yaw "0.0" +## Команды игрока -// Позиция спавна игрока 2 -sm_duels_arena_spawn2_x "200.0" -sm_duels_arena_spawn2_y "0.0" -sm_duels_arena_spawn2_z "0.0" -sm_duels_arena_spawn2_yaw "180.0" -``` +| Команда | Описание | +|---|---| +| `!duel` / `sm_duel` | Информация о системе дуэлей | +| `!duelstats` / `sm_duelstats` | Личная статистика (победы/поражения/винрейт) | +| `!duel_top` / `sm_duel_top` | Топ-10 игроков по победам | + +## Команды админа (флаг `z` / ROOT) + +| Команда | Описание | +|---|---| +| `sm_duel_setspawn1` / `sm_duel_setspawn2` | Сохранить точку спавна #1/#2 | +| `sm_duel_setzone1` / `sm_duel_setzone2` | Углы зоны ограничения | +| `sm_duel_savearena` | Сохранить арену для текущей карты | +| `sm_duel_reloadarena` | Перезагрузить конфиг арены | +| `sm_duel_arena_info` | Показать данные арены | +| `sm_duel_showzone` | Показать визуально зону | +| `sm_duel_debugsolo` | Соло-дебаг (без 2-го игрока) | +| `sm_duel_forceend` | Принудительно завершить активную дуэль | ## ConVars | ConVar | По умолчанию | Описание | |---|---|---| -| `sm_duels_enable` | `1` | Включить/выключить дуэли | -| `sm_duels_use_arena` | `1` | Использовать арену | -| `sm_duels_beacon` | `1` | Включить beacon у дуэлянтов | -| `sm_duels_prepare_time` | `5` | Время подготовки перед дуэлью (сек) | -| `sm_duels_win_xp` | `50` | XP за победу | -| `sm_duels_time_limit` | `120` | Лимит времени дуэли (сек) | -| `sm_duels_zone_enable` | `1` | Включить зону ограничения | -| `sm_duels_zone_grace` | `3.0` | Время предупреждения перед кикбеком (сек) | -| `sm_duels_allow_deagle` | `1` | Разрешить Deagle | -| `sm_duels_allow_ak47` | `1` | Разрешить AK-47 | -| `sm_duels_allow_awp` | `1` | Разрешить AWP | -| `sm_duels_debug_solo` | `0` | Debug: одиночный режим (без второго игрока) | +| `agd_core_enable` | `1` | Включить дуэли | +| `agd_use_arena` | `1` | Использовать арену | +| `agd_duel_beacon` | `1` | Включить beacon | +| `agd_duel_prepare_time` | `10` | Время подготовки (сек) | +| `agd_duel_post_unfreeze_invuln` | `2.0` | Неуязвимость после анфриза | +| `agd_duel_win_money` | `1500` | Деньги за победу | +| `agd_duel_win_xp` | `100` | XP за победу | +| `agd_duel_time_limit` | `30` | Лимит дуэли (сек, 0 = нет) | +| `agd_weapon_allow_*` | `1` | Разрешить оружие (knife/deagle/ak47/m4a1/m4a1s/awp/scout) | +| `agd_duel_bypass_restrict_flags` | `1` | Дать ROOT-флаг во время дуэли (обход других плагинов) | +| `agd_zone_*` | разные | Параметры зоны ограничения | +| `agd_debug_allow_solo` | `1` | Разрешить sm_duel_debugsolo | +| `agd_duel_block_external` | `0` | **Внешняя блокировка** (другой плагин может выставить в `1`) | +| `agd_duel_ragequit_xp_penalty` | `50` | Штраф XP за выход во время дуэли | +| `agd_duel_stats_enable` | `1` | Сохранять статистику в файл | +| `agd_duel_log_enable` | `1` | Логировать дуэли в файл | -## Forwards (для других плагинов) +## Forwards / Natives ```sourcepawn -// Дуэль началась -forward void OnDuelStarted(int player1, int player2); +forward void AGD_OnDuelStarted(int player1, int player2); +forward void AGD_OnDuelFinished(int winner, int loser, int winnerXP, int loserXP); +forward void AGD_OnDuelDraw(int player1, int player2); -// Дуэль завершилась -forward void OnDuelFinished(int winner, int loser); - -// Дуэль завершилась ничьей -forward void OnDuelDraw(int player1, int player2); +native bool AGD_IsDuelActive(); +native bool AGD_IsBettingOpen(); +native bool AGD_GetDuelPlayers(int &p1, int &p2); +native int AGD_GetDuelParticipantXP(int client); +native int AGD_GetWinnerMoneyReward(); +native bool AGD_IsArenaReady(); +native bool AGD_IsClientInDuel(int client); ``` +## Файлы данных + +- **Статистика:** `addons/sourcemod/data/arcane_duels_stats.cfg` (KeyValues per Steam ID) +- **Лог:** `addons/sourcemod/logs/arcane_duels.log` (текстовый, формат `[YYYY-MM-DD HH:MM:SS] EVENT: detail`) + ## Версия -`1.5.3` — Автор: OpenAI / havno +`1.6.0` — Автор: deidara.dev + +### Changelog + +- **1.6.0** + - Жёлтый префикс `[DUELS]` (был красный) + - **Полное** сохранение/восстановление инвентаря: гранаты, шлем, дефузер, патроны в клипе и резерве + - `RefillWeaponAmmo` теперь знает 25+ оружий (все винтовки, SMG, дробовики, пистолеты, снайперки) + - Добавлен пункт «Случайное оружие» в weapon menu + - Новая админ-команда `sm_duel_forceend` — принудительно остановить любую активную/готовящуюся дуэль + - **Файловый лог** дуэлей `addons/sourcemod/logs/arcane_duels.log` + - **Статистика** per-игрок: wins/losses/draws/rage-quits + персистенция через `addons/sourcemod/data/arcane_duels_stats.cfg` + - Команды `!duelstats` (личная стата) и `!duel_top` (топ-10 по победам) + - **Rage-quit penalty**: XP-штраф (по cvar) и +1 поражение/rage-quit в стате + - **Звуковые эффекты**: победителю — фанфара, проигравшему — death-камера + - ConVar `agd_duel_block_external` — внешние плагины могут временно блокировать авто-дуэли + - Убран двойной reset state в Event_RoundStart + - Author = `deidara.dev` (был `OpenAI / havno`) +- **1.5.3** — предыдущая версия diff --git a/scripting/ArcaneGameDUELS_Core.sp b/scripting/ArcaneGameDUELS_Core.sp index ee0508f..f931972 100644 --- a/scripting/ArcaneGameDUELS_Core.sp +++ b/scripting/ArcaneGameDUELS_Core.sp @@ -9,17 +9,22 @@ #define AGD_LIBRARY "arcane_duels_core" #define ARENA_CFG "cfg/sourcemod/ArcaneGameDUELS_Arena.cfg" +#define STATS_CFG "data/arcane_duels_stats.cfg" +#define DUEL_LOG_FILE "addons/sourcemod/logs/arcane_duels.log" #define BEACON_SOUND "buttons/blip1.wav" -#define DUEL_PREFIX "\x02[DUELS]\x01" #define DUEL_OUT_SOUND "buttons/button10.wav" +#define DUEL_WIN_SOUND "ui/csgo_ui_contract_type1.wav" +#define DUEL_LOSE_SOUND "ui/deathcam.wav" +#define DUEL_PREFIX "\x09[DUELS]\x01" +#define MAX_SAVED_GRENADES 6 public Plugin myinfo = { name = "[CORE] ArcaneGameDUELS Core", - author = "OpenAI / havno", - description = "Core of ArcaneGameDUELS (duel deagle compatibility build)", - version = "1.5.3", - url = "" + author = "deidara.dev", + description = "1v1 дуэли с ареной, зоной, выбором оружия, статистикой и логированием", + version = "1.6.0", + url = "https://deidara.dev" }; bool g_bDuelActive = false; @@ -44,11 +49,26 @@ enum DuelModifier DuelModifier g_iDuelModifier = DuelModifier_None; char g_sSavedWeapon[MAXPLAYERS + 1][3][32]; +int g_iSavedClip[MAXPLAYERS + 1][3]; +int g_iSavedReserve[MAXPLAYERS + 1][3]; int g_iSavedHealth[MAXPLAYERS + 1]; int g_iSavedArmor[MAXPLAYERS + 1]; int g_iSavedMoney[MAXPLAYERS + 1]; int g_iSavedUserFlags[MAXPLAYERS + 1]; bool g_bSavedUserFlags[MAXPLAYERS + 1]; +bool g_bSavedHelmet[MAXPLAYERS + 1]; +bool g_bSavedDefuser[MAXPLAYERS + 1]; +char g_sSavedGrenades[MAXPLAYERS + 1][MAX_SAVED_GRENADES][32]; +int g_iSavedGrenadeCount[MAXPLAYERS + 1]; + +// Статистика дуэлей +StringMap g_smStats = null; + +// ConVars (новые) +ConVar gCvarBlockExternal; +ConVar gCvarRageQuitPenalty; +ConVar gCvarStatsEnable; +ConVar gCvarLogEnable; Handle g_hFwdOnDuelStarted = null; Handle g_hFwdOnDuelFinished = null; @@ -147,6 +167,10 @@ public void OnPluginStart() gCvarZoneRenderInterval = CreateConVar("agd_zone_render_interval", "0.5", "Interval for duel zone rendering and checks", _, true, 0.1, true, 2.0); gCvarDebugAllowSolo = CreateConVar("agd_debug_allow_solo", "1", "Allow root admins to start solo duel debug session", _, true, 0.0, true, 1.0); gCvarDuelTimeLimit = CreateConVar("agd_duel_time_limit", "30", "Time limit for duel in seconds (0 = no limit)", _, true, 0.0, true, 300.0); + gCvarBlockExternal = CreateConVar("agd_duel_block_external", "0", "External plugins (custom-rounds и т.п.) могут установить в 1 чтобы временно блокировать авто-дуэли", _, true, 0.0, true, 1.0); + gCvarRageQuitPenalty = CreateConVar("agd_duel_ragequit_xp_penalty", "50", "XP penalty for disconnecting during active duel (0 = none)", _, true, 0.0, true, 1000000.0); + gCvarStatsEnable = CreateConVar("agd_duel_stats_enable", "1", "Сохранять статистику дуэлей (wins/losses) в файл", _, true, 0.0, true, 1.0); + gCvarLogEnable = CreateConVar("agd_duel_log_enable", "1", "Логировать дуэли в addons/sourcemod/logs/arcane_duels.log", _, true, 0.0, true, 1.0); AutoExecConfig(true, "ArcaneGameDUELS_Core"); @@ -155,6 +179,8 @@ public void OnPluginStart() g_hFwdOnDuelDraw = CreateGlobalForward("AGD_OnDuelDraw", ET_Ignore, Param_Cell, Param_Cell); RegConsoleCmd("sm_duel", Command_DuelMenu); + RegConsoleCmd("sm_duelstats", Command_DuelStats, "Показать свою статистику дуэлей"); + RegConsoleCmd("sm_duel_top", Command_DuelTop, "Топ-10 игроков по победам"); RegAdminCmd("sm_duel_setspawn1", Command_SetSpawn1, ADMFLAG_ROOT); RegAdminCmd("sm_duel_setspawn2", Command_SetSpawn2, ADMFLAG_ROOT); @@ -165,6 +191,7 @@ public void OnPluginStart() RegAdminCmd("sm_duel_arena_info", Command_ArenaInfo, ADMFLAG_ROOT); RegAdminCmd("sm_duel_showzone", Command_ShowZone, ADMFLAG_ROOT); RegAdminCmd("sm_duel_debugsolo", Command_DebugSolo, ADMFLAG_ROOT); + RegAdminCmd("sm_duel_forceend", Command_ForceEnd, ADMFLAG_ROOT, "Принудительно завершить активную/готовящуюся дуэль"); HookEvent("player_death", Event_PlayerDeath, EventHookMode_Post); HookEvent("round_start", Event_RoundStart, EventHookMode_PostNoCopy); @@ -175,6 +202,9 @@ public void OnPluginStart() LoadArenaForCurrentMap(); + g_smStats = new StringMap(); + LoadDuelStats(); + for (int i = 1; i <= MaxClients; i++) { if (IsClientInGame(i)) @@ -182,6 +212,11 @@ public void OnPluginStart() } } +public void OnMapEnd() +{ + SaveDuelStats(); +} + public void OnMapStart() { LoadArenaForCurrentMap(); @@ -225,6 +260,9 @@ public void OnClientDisconnect(int client) int winner = (client == g_iDuelP1) ? g_iDuelP2 : g_iDuelP1; int loser = client; + // Rage-quit penalty: записываем поражение и снимаем XP, если cvar > 0 + ApplyRageQuitPenalty(loser); + if (IsValidHuman(winner)) FinishDuel(winner, loser); else @@ -425,6 +463,8 @@ public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) g_bBombPlanted = false; CancelPendingDuel(""); CancelPreparingDuel(""); + // ResetDuelState уже вызывается внутри CancelPreparingDuel; повторный вызов оставляем + // только если дуэль реально активна (не в подготовке) if (g_bDuelActive) ResetDuelState(); @@ -529,6 +569,10 @@ public Action Timer_CheckForOneVsOne(Handle timer, any data) if (!gCvarEnable.BoolValue || g_bBombPlanted || g_bDuelActive || g_bDuelPending || g_bDuelPreparing) return Plugin_Stop; + // Внешняя блокировка (например custom-rounds может выставить cvar в 1) + if (gCvarBlockExternal != null && gCvarBlockExternal.BoolValue) + return Plugin_Stop; + if (g_iRoundStartPlayingHumans <= 2) return Plugin_Stop; @@ -559,7 +603,7 @@ void OfferAutomaticDuel(int client1, int client2) g_bAccepted[client1] = false; g_bAccepted[client2] = false; - PrintToChatAll("\x02[DUELS]\x01 Остались 1 на 1: \x03%N\x01 vs \x03%N\x01. Ожидается подтверждение дуэли.", client1, client2); + PrintToChatAll("\x09[DUELS]\x01 Остались 1 на 1: \x03%N\x01 vs \x03%N\x01. Ожидается подтверждение дуэли.", client1, client2); ShowAcceptMenu(client1); ShowAcceptMenu(client2); } @@ -599,7 +643,7 @@ public int MenuHandler_Accept(Menu menu, MenuAction action, int client, int item if (StringToInt(info) == 1) { g_bAccepted[client] = true; - PrintToChat(client, "\x02[DUELS]\x01 Ты принял дуэль."); + PrintToChat(client, "\x09[DUELS]\x01 Ты принял дуэль."); if (g_bAccepted[g_iDuelP1] && g_bAccepted[g_iDuelP2]) BeginPreparation(g_iDuelP1, g_iDuelP2); @@ -642,8 +686,8 @@ void BeginPreparation(int client1, int client2) StartPrepZoneTimer(); - PrintToChatAll("\x02[DUELS]\x01 Дуэль принята. Настройки выбирает случайный игрок: \x03%N", g_iDuelController); - PrintToChatAll("\x02[DUELS]\x01 Подготовка к дуэли: \x05%d\x01 сек. В это время доступны ставки.", g_iPrepareLeft); + PrintToChatAll("\x09[DUELS]\x01 Дуэль принята. Настройки выбирает случайный игрок: \x03%N", g_iDuelController); + PrintToChatAll("\x09[DUELS]\x01 Подготовка к дуэли: \x05%d\x01 сек. В это время доступны ставки.", g_iPrepareLeft); ShowWeaponMenu(g_iDuelController); @@ -698,6 +742,9 @@ void ShowWeaponMenu(int client) if (gCvarAllowAWP.BoolValue) { menu.AddItem("weapon_awp", "AWP"); added++; } if (gCvarAllowScout.BoolValue) { menu.AddItem("weapon_ssg08", "SSG08 / Scout"); added++; } + if (added > 1) + menu.AddItem("__random__", "Случайное оружие"); + if (added <= 0) menu.AddItem("blocked", "Нет доступного оружия", ITEMDRAW_DISABLED); @@ -705,6 +752,26 @@ void ShowWeaponMenu(int client) menu.Display(client, 10); } +void PickRandomAllowedWeapon(char[] buffer, int maxlen) +{ + char candidates[8][32]; + int count = 0; + if (gCvarAllowKnife.BoolValue) { strcopy(candidates[count++], 32, "weapon_knife"); } + if (gCvarAllowDeagle.BoolValue) { strcopy(candidates[count++], 32, "weapon_deagle"); } + if (gCvarAllowAK47.BoolValue) { strcopy(candidates[count++], 32, "weapon_ak47"); } + if (gCvarAllowM4A4.BoolValue) { strcopy(candidates[count++], 32, "weapon_m4a1"); } + if (gCvarAllowAWP.BoolValue) { strcopy(candidates[count++], 32, "weapon_awp"); } + if (gCvarAllowScout.BoolValue) { strcopy(candidates[count++], 32, "weapon_ssg08"); } + + if (count == 0) + { + strcopy(buffer, maxlen, "weapon_ak47"); + return; + } + + strcopy(buffer, maxlen, candidates[GetRandomInt(0, count - 1)]); +} + public int MenuHandler_WeaponMenu(Menu menu, MenuAction action, int client, int item) { switch (action) @@ -718,7 +785,15 @@ public int MenuHandler_WeaponMenu(Menu menu, MenuAction action, int client, int char info[32]; menu.GetItem(item, info, sizeof(info)); - strcopy(g_sSelectedWeapon, sizeof(g_sSelectedWeapon), info); + + if (StrEqual(info, "__random__")) + { + PickRandomAllowedWeapon(g_sSelectedWeapon, sizeof(g_sSelectedWeapon)); + } + else + { + strcopy(g_sSelectedWeapon, sizeof(g_sSelectedWeapon), info); + } g_iDuelModifier = DuelModifier_None; if (!StrEqual(g_sSelectedWeapon, "weapon_knife")) @@ -767,9 +842,9 @@ void AnnounceSelectedSettings() char weaponName[32]; GetDuelWeaponName(weaponName, sizeof(weaponName)); if (g_iDuelModifier == DuelModifier_HeadshotOnly) - PrintToChatAll("\x02[DUELS]\x01 Выбрана дуэль: \x03%s\x01 | режим: \x05Только в голову", weaponName); + PrintToChatAll("\x09[DUELS]\x01 Выбрана дуэль: \x03%s\x01 | режим: \x05Только в голову", weaponName); else - PrintToChatAll("\x02[DUELS]\x01 Выбрана дуэль: \x03%s\x01 | режим: \x05Обычный", weaponName); + PrintToChatAll("\x09[DUELS]\x01 Выбрана дуэль: \x03%s\x01 | режим: \x05Обычный", weaponName); } public Action Timer_PrepareCountdown(Handle timer, any data) @@ -848,8 +923,8 @@ void StartDuel(int client1, int client2) char weaponName[32]; GetDuelWeaponName(weaponName, sizeof(weaponName)); - PrintToChatAll("\x02[DUELS]\x01 Дуэль началась: \x03%N\x01 vs \x03%N\x01 | \x05%s", client1, client2, weaponName); - PrintToChatAll("\x02[DUELS]\x01 Заморозка снята. Неуязвимость будет убрана через \x05%.0f\x01 сек. | Лимит дуэли: \x05%d\x01 сек.", gCvarPostUnfreezeInvuln.FloatValue, gCvarDuelTimeLimit.IntValue); + PrintToChatAll("\x09[DUELS]\x01 Дуэль началась: \x03%N\x01 vs \x03%N\x01 | \x05%s", client1, client2, weaponName); + PrintToChatAll("\x09[DUELS]\x01 Заморозка снята. Неуязвимость будет убрана через \x05%.0f\x01 сек. | Лимит дуэли: \x05%d\x01 сек.", gCvarPostUnfreezeInvuln.FloatValue, gCvarDuelTimeLimit.IntValue); Call_StartForward(g_hFwdOnDuelStarted); Call_PushCell(client1); @@ -924,7 +999,18 @@ void FinishDuel(int winner, int loser, bool endRoundByTeamDefeat = false) } } - PrintToChatAll("\x02[DUELS]\x01 Дуэль завершена. Победитель: \x03%N\x01 | Награда: \x05%d$\x01 | Базовый XP: \x05%d", winner, moneyReward, duelWinXP); + PrintToChatAll("\x09[DUELS]\x01 Дуэль завершена. Победитель: \x03%N\x01 | Награда: \x05%d$\x01 | Базовый XP: \x05%d", winner, moneyReward, duelWinXP); + + // Звуки победителю/проигравшему + if (IsValidHuman(winner)) + EmitSoundToClient(winner, DUEL_WIN_SOUND); + if (IsValidHuman(loser)) + EmitSoundToClient(loser, DUEL_LOSE_SOUND); + + // Лог + статистика + LogDuelAction("WIN: %N (winner) vs %N (loser) | weapon=%s | XP=%d | money=%d", + winner, loser, g_sSelectedWeapon, duelWinXP, moneyReward); + RecordDuelResult(winner, loser, duelWinXP); Call_StartForward(g_hFwdOnDuelFinished); Call_PushCell(winner); @@ -956,6 +1042,11 @@ void FinishDuelDraw(int client1, int client2) PrintToChatAll("%s Дуэль завершилась вничью. Ставки должны быть возвращены модулем ставок.", DUEL_PREFIX); + // Лог + статистика + LogDuelAction("DRAW: %N vs %N | weapon=%s", + IsValidClient(client1) ? client1 : 0, IsValidClient(client2) ? client2 : 0, g_sSelectedWeapon); + RecordDuelDraw(client1, client2); + Call_StartForward(g_hFwdOnDuelDraw); Call_PushCell(client1); Call_PushCell(client2); @@ -995,7 +1086,7 @@ void CancelPendingDuel(const char[] reason) return; if (reason[0] != '\0') - PrintToChatAll("\x02[DUELS]\x01 %s", reason); + PrintToChatAll("\x09[DUELS]\x01 %s", reason); g_bDuelPending = false; if (g_iDuelP1 > 0) @@ -1021,7 +1112,7 @@ void CancelPreparingDuel(const char[] reason) SetDuelProtection(g_iDuelP2, false); if (reason[0] != '\0') - PrintToChatAll("\x02[DUELS]\x01 %s", reason); + PrintToChatAll("\x09[DUELS]\x01 %s", reason); RestoreLoadout(g_iDuelP1); RestoreLoadout(g_iDuelP2); @@ -1129,7 +1220,7 @@ public Action Timer_DuelTimeLimit(Handle timer, any data) if (!g_bDuelActive) return Plugin_Stop; - PrintToChatAll("\x02[DUELS]\x01 Время дуэли истекло. Ничья!"); + PrintToChatAll("\x09[DUELS]\x01 Время дуэли истекло. Ничья!"); FinishDuelDraw(g_iDuelP1, g_iDuelP2); return Plugin_Stop; } @@ -1540,22 +1631,61 @@ void SaveLoadout(int client) if (!IsValidHuman(client)) return; - SaveWeaponClass(client, 0, 0); - SaveWeaponClass(client, 1, 1); - SaveWeaponClass(client, 2, 2); + SaveWeaponClassAndAmmo(client, 0, 0); + SaveWeaponClassAndAmmo(client, 1, 1); + SaveWeaponClassAndAmmo(client, 2, 2); + SaveAllGrenadesForDuel(client); g_iSavedHealth[client] = GetClientHealth(client); g_iSavedArmor[client] = GetEntProp(client, Prop_Send, "m_ArmorValue"); g_iSavedMoney[client] = GetEntProp(client, Prop_Send, "m_iAccount"); + g_bSavedHelmet[client] = GetEntProp(client, Prop_Send, "m_bHasHelmet") != 0; + g_bSavedDefuser[client] = HasEntProp(client, Prop_Send, "m_bHasDefuser") && GetEntProp(client, Prop_Send, "m_bHasDefuser") != 0; } -void SaveWeaponClass(int client, int slot, int saveIndex) +void SaveWeaponClassAndAmmo(int client, int slot, int saveIndex) { + g_sSavedWeapon[client][saveIndex][0] = '\0'; + g_iSavedClip[client][saveIndex] = 0; + g_iSavedReserve[client][saveIndex] = 0; + int weapon = GetPlayerWeaponSlot(client, slot); - if (weapon > MaxClients && IsValidEdict(weapon)) - GetEdictClassname(weapon, g_sSavedWeapon[client][saveIndex], 32); - else - g_sSavedWeapon[client][saveIndex][0] = '\0'; + if (weapon <= MaxClients || !IsValidEdict(weapon)) + return; + + GetEdictClassname(weapon, g_sSavedWeapon[client][saveIndex], 32); + + if (HasEntProp(weapon, Prop_Send, "m_iClip1")) + g_iSavedClip[client][saveIndex] = GetEntProp(weapon, Prop_Send, "m_iClip1"); + if (HasEntProp(weapon, Prop_Send, "m_iPrimaryReserveAmmoCount")) + g_iSavedReserve[client][saveIndex] = GetEntProp(weapon, Prop_Send, "m_iPrimaryReserveAmmoCount"); +} + +void SaveAllGrenadesForDuel(int client) +{ + g_iSavedGrenadeCount[client] = 0; + + int offset = FindSendPropInfo("CCSPlayer", "m_hMyWeapons"); + if (offset <= 0) + return; + + char classname[32]; + for (int i = 0; i < 64; i++) + { + int weapon = GetEntDataEnt2(client, offset + (i * 4)); + if (weapon == -1 || !IsValidEntity(weapon)) + continue; + + GetEntityClassname(weapon, classname, sizeof(classname)); + if (!IsGrenadeWeapon(classname)) + continue; + + if (g_iSavedGrenadeCount[client] < MAX_SAVED_GRENADES) + { + strcopy(g_sSavedGrenades[client][g_iSavedGrenadeCount[client]], 32, classname); + g_iSavedGrenadeCount[client]++; + } + } } void RestoreLoadout(int client) @@ -1572,13 +1702,39 @@ void RestoreLoadout(int client) continue; int weapon = GivePlayerItem(client, g_sSavedWeapon[client][i]); - if (weapon > MaxClients) - RefillWeaponAmmo(weapon); + if (weapon > MaxClients && IsValidEntity(weapon)) + { + // Восстанавливаем сохранённый клип/резерв; если их не было — refill дефолтом + bool restored = false; + if (g_iSavedClip[client][i] > 0 && HasEntProp(weapon, Prop_Send, "m_iClip1")) + { + SetEntProp(weapon, Prop_Send, "m_iClip1", g_iSavedClip[client][i]); + restored = true; + } + if (g_iSavedReserve[client][i] > 0 && HasEntProp(weapon, Prop_Send, "m_iPrimaryReserveAmmoCount")) + { + SetEntProp(weapon, Prop_Send, "m_iPrimaryReserveAmmoCount", g_iSavedReserve[client][i]); + restored = true; + } + if (!restored) + RefillWeaponAmmo(weapon); + } } + // Гранаты + for (int i = 0; i < g_iSavedGrenadeCount[client]; i++) + { + if (g_sSavedGrenades[client][i][0] != '\0') + GivePlayerItem(client, g_sSavedGrenades[client][i]); + } + g_iSavedGrenadeCount[client] = 0; + SetEntityHealth(client, g_iSavedHealth[client] > 0 ? g_iSavedHealth[client] : 100); SetEntProp(client, Prop_Send, "m_ArmorValue", g_iSavedArmor[client]); SetEntProp(client, Prop_Send, "m_iAccount", g_iSavedMoney[client]); + SetEntProp(client, Prop_Send, "m_bHasHelmet", g_bSavedHelmet[client] ? 1 : 0); + if (GetClientTeam(client) == CS_TEAM_CT && HasEntProp(client, Prop_Send, "m_bHasDefuser")) + SetEntProp(client, Prop_Send, "m_bHasDefuser", g_bSavedDefuser[client] ? 1 : 0); } void RemoveAllWeapons(int client) @@ -1619,31 +1775,44 @@ void RefillWeaponAmmo(int weapon) int clip = 30; int reserve = 90; - if (StrEqual(classname, "weapon_deagle")) - { - clip = 7; - reserve = 35; - } - else if (StrEqual(classname, "weapon_awp")) - { - clip = 10; - reserve = 30; - } - else if (StrEqual(classname, "weapon_ssg08")) - { - clip = 10; - reserve = 90; - } - else if (StrEqual(classname, "weapon_m4a1_silencer")) - { - clip = 20; - reserve = 60; - } - else if (StrEqual(classname, "weapon_m4a1") || StrEqual(classname, "weapon_ak47")) - { - clip = 30; - reserve = 90; - } + // Пистолеты + if (StrEqual(classname, "weapon_deagle")) { clip = 7; reserve = 35; } + else if (StrEqual(classname, "weapon_revolver")) { clip = 8; reserve = 8; } + else if (StrEqual(classname, "weapon_glock")) { clip = 20; reserve = 120; } + else if (StrEqual(classname, "weapon_hkp2000")) { clip = 13; reserve = 52; } + else if (StrEqual(classname, "weapon_usp_silencer")) { clip = 12; reserve = 24; } + else if (StrEqual(classname, "weapon_p250")) { clip = 13; reserve = 26; } + else if (StrEqual(classname, "weapon_fiveseven")) { clip = 20; reserve = 100; } + else if (StrEqual(classname, "weapon_tec9")) { clip = 18; reserve = 90; } + else if (StrEqual(classname, "weapon_cz75a")) { clip = 12; reserve = 12; } + else if (StrEqual(classname, "weapon_elite")) { clip = 30; reserve = 120; } + // Снайперки + else if (StrEqual(classname, "weapon_awp")) { clip = 10; reserve = 30; } + else if (StrEqual(classname, "weapon_ssg08")) { clip = 10; reserve = 90; } + else if (StrEqual(classname, "weapon_scar20")) { clip = 20; reserve = 90; } + else if (StrEqual(classname, "weapon_g3sg1")) { clip = 20; reserve = 90; } + // Винтовки + else if (StrEqual(classname, "weapon_m4a1_silencer")) { clip = 20; reserve = 80; } + else if (StrEqual(classname, "weapon_m4a1") || StrEqual(classname, "weapon_ak47")) { clip = 30; reserve = 90; } + else if (StrEqual(classname, "weapon_aug")) { clip = 30; reserve = 90; } + else if (StrEqual(classname, "weapon_sg556")) { clip = 30; reserve = 90; } + else if (StrEqual(classname, "weapon_galilar")) { clip = 35; reserve = 90; } + else if (StrEqual(classname, "weapon_famas")) { clip = 25; reserve = 90; } + // SMG + else if (StrEqual(classname, "weapon_mp9")) { clip = 30; reserve = 120; } + else if (StrEqual(classname, "weapon_mp7")) { clip = 30; reserve = 120; } + else if (StrEqual(classname, "weapon_mp5sd")) { clip = 30; reserve = 120; } + else if (StrEqual(classname, "weapon_mac10")) { clip = 30; reserve = 100; } + else if (StrEqual(classname, "weapon_ump45")) { clip = 25; reserve = 100; } + else if (StrEqual(classname, "weapon_p90")) { clip = 50; reserve = 100; } + else if (StrEqual(classname, "weapon_bizon")) { clip = 64; reserve = 120; } + // Дробовики / тяжёлое + else if (StrEqual(classname, "weapon_nova")) { clip = 8; reserve = 32; } + else if (StrEqual(classname, "weapon_xm1014")) { clip = 7; reserve = 32; } + else if (StrEqual(classname, "weapon_mag7")) { clip = 5; reserve = 32; } + else if (StrEqual(classname, "weapon_sawedoff")) { clip = 7; reserve = 32; } + else if (StrEqual(classname, "weapon_m249")) { clip = 100; reserve = 200; } + else if (StrEqual(classname, "weapon_negev")) { clip = 150; reserve = 200; } if (HasEntProp(weapon, Prop_Send, "m_iClip1")) SetEntProp(weapon, Prop_Send, "m_iClip1", clip); @@ -2199,3 +2368,371 @@ public any Native_AGD_IsBettingOpen(Handle plugin, int numParams) { return g_bDuelPreparing; } + +// ============================================================================ +// Force-end (admin) +// ============================================================================ + +public Action Command_ForceEnd(int client, int args) +{ + if (!g_bDuelActive && !g_bDuelPending && !g_bDuelPreparing) + { + ReplyToCommand(client, "%s Сейчас нет активной/готовящейся дуэли.", DUEL_PREFIX); + return Plugin_Handled; + } + + PrintToChatAll("%s Админ \x03%N\x01 принудительно завершил дуэль.", DUEL_PREFIX, client); + LogDuelAction("FORCE_END by %N (admin)", client); + + if (g_bDuelPending) + { + CancelPendingDuel(""); + } + else if (g_bDuelPreparing) + { + CancelPreparingDuel(""); + } + else if (g_bDuelActive) + { + StopBeaconTimer(); + StopInvulnTimer(); + StopZoneTimer(); + StopZonePreview(); + StopDuelLimitTimer(); + SetDuelProtection(g_iDuelP1, false); + SetDuelProtection(g_iDuelP2, false); + RestoreLoadout(g_iDuelP1); + RestoreLoadout(g_iDuelP2); + ResetDuelState(); + } + + ReplyToCommand(client, "%s Дуэль остановлена.", DUEL_PREFIX); + return Plugin_Handled; +} + +// ============================================================================ +// Файловое логирование +// ============================================================================ + +void LogDuelAction(const char[] format, any ...) +{ + if (gCvarLogEnable == null || !gCvarLogEnable.BoolValue) + return; + + char message[512]; + VFormat(message, sizeof(message), format, 2); + + char timestamp[32]; + FormatTime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S"); + + File logFile = OpenFile(DUEL_LOG_FILE, "a"); + if (logFile != null) + { + logFile.WriteLine("[%s] %s", timestamp, message); + delete logFile; + } + else + { + LogError("[DUELS] Не удалось открыть %s на запись.", DUEL_LOG_FILE); + } +} + +// ============================================================================ +// Статистика дуэлей +// ============================================================================ + +enum struct DuelStats +{ + int wins; + int losses; + int draws; + int rageQuits; + int totalXP; + char name[MAX_NAME_LENGTH]; +} + +void LoadDuelStats() +{ + if (g_smStats == null) + g_smStats = new StringMap(); + + g_smStats.Clear(); + + char path[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, path, sizeof(path), "../../" ... STATS_CFG); + + KeyValues kv = new KeyValues("ArcaneDuelsStats"); + if (!kv.ImportFromFile(path)) + { + delete kv; + return; + } + + if (!kv.GotoFirstSubKey()) + { + delete kv; + return; + } + + do + { + char steam[32]; + kv.GetSectionName(steam, sizeof(steam)); + + DuelStats stats; + kv.GetString("name", stats.name, sizeof(DuelStats::name), ""); + stats.wins = kv.GetNum("wins", 0); + stats.losses = kv.GetNum("losses", 0); + stats.draws = kv.GetNum("draws", 0); + stats.rageQuits = kv.GetNum("rage_quits", 0); + stats.totalXP = kv.GetNum("total_xp", 0); + + g_smStats.SetArray(steam, stats, sizeof(stats)); + } + while (kv.GotoNextKey()); + + delete kv; +} + +void SaveDuelStats() +{ + if (g_smStats == null) + return; + + char path[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, path, sizeof(path), "../../" ... STATS_CFG); + + KeyValues kv = new KeyValues("ArcaneDuelsStats"); + + StringMapSnapshot snap = g_smStats.Snapshot(); + for (int i = 0; i < snap.Length; i++) + { + char steam[32]; + snap.GetKey(i, steam, sizeof(steam)); + + DuelStats stats; + if (!g_smStats.GetArray(steam, stats, sizeof(stats))) + continue; + + kv.JumpToKey(steam, true); + kv.SetString("name", stats.name); + kv.SetNum("wins", stats.wins); + kv.SetNum("losses", stats.losses); + kv.SetNum("draws", stats.draws); + kv.SetNum("rage_quits", stats.rageQuits); + kv.SetNum("total_xp", stats.totalXP); + kv.GoBack(); + } + delete snap; + + kv.Rewind(); + kv.ExportToFile(path); + delete kv; +} + +bool GetClientStats(int client, DuelStats stats) +{ + if (!IsValidHuman(client)) + return false; + + char steam[32]; + if (!GetClientAuthId(client, AuthId_Steam2, steam, sizeof(steam))) + return false; + + if (g_smStats == null) + return false; + + if (!g_smStats.GetArray(steam, stats, sizeof(stats))) + return false; + + return true; +} + +void UpdateClientStats(int client, DuelStats stats) +{ + if (!IsValidHuman(client) || g_smStats == null) + return; + + char steam[32]; + if (!GetClientAuthId(client, AuthId_Steam2, steam, sizeof(steam))) + return; + + GetClientName(client, stats.name, sizeof(DuelStats::name)); + g_smStats.SetArray(steam, stats, sizeof(stats)); +} + +void RecordDuelResult(int winner, int loser, int xp) +{ + if (gCvarStatsEnable == null || !gCvarStatsEnable.BoolValue) + return; + + if (IsValidHuman(winner)) + { + DuelStats s; + GetClientStats(winner, s); + s.wins++; + s.totalXP += xp; + UpdateClientStats(winner, s); + } + + if (IsValidHuman(loser)) + { + DuelStats s; + GetClientStats(loser, s); + s.losses++; + UpdateClientStats(loser, s); + } + + SaveDuelStats(); +} + +void RecordDuelDraw(int p1, int p2) +{ + if (gCvarStatsEnable == null || !gCvarStatsEnable.BoolValue) + return; + + if (IsValidHuman(p1)) + { + DuelStats s; + GetClientStats(p1, s); + s.draws++; + UpdateClientStats(p1, s); + } + if (IsValidHuman(p2)) + { + DuelStats s; + GetClientStats(p2, s); + s.draws++; + UpdateClientStats(p2, s); + } + SaveDuelStats(); +} + +void ApplyRageQuitPenalty(int client) +{ + if (!IsValidHuman(client)) + return; + + int penalty = (gCvarRageQuitPenalty != null) ? gCvarRageQuitPenalty.IntValue : 0; + + if (gCvarStatsEnable != null && gCvarStatsEnable.BoolValue) + { + DuelStats s; + GetClientStats(client, s); + s.losses++; + s.rageQuits++; + UpdateClientStats(client, s); + SaveDuelStats(); + } + + if (penalty > 0 && IsLRReady() && LR_GetClientStatus(client)) + { + LR_ChangeClientValue(client, -penalty); + } + + LogDuelAction("RAGE_QUIT: %N | penalty=%d", client, penalty); +} + +// ============================================================================ +// Команды для игроков: !duelstats и !duel_top +// ============================================================================ + +public Action Command_DuelStats(int client, int args) +{ + if (!IsValidHuman(client)) + { + ReplyToCommand(client, "%s Команда доступна только в игре.", DUEL_PREFIX); + return Plugin_Handled; + } + + DuelStats s; + if (!GetClientStats(client, s)) + { + PrintToChat(client, "%s У тебя пока нет дуэлей.", DUEL_PREFIX); + return Plugin_Handled; + } + + int total = s.wins + s.losses + s.draws; + int winRate = (total > 0) ? (s.wins * 100 / total) : 0; + + PrintToChat(client, "%s Твоя статистика дуэлей:", DUEL_PREFIX); + PrintToChat(client, "%s Победы: \x05%d\x01 | Поражения: \x05%d\x01 | Ничьи: \x05%d", DUEL_PREFIX, s.wins, s.losses, s.draws); + PrintToChat(client, "%s Винрейт: \x04%d%%\x01 | Rage-quits: \x05%d\x01 | Всего XP: \x05%d", DUEL_PREFIX, winRate, s.rageQuits, s.totalXP); + + return Plugin_Handled; +} + +public Action Command_DuelTop(int client, int args) +{ + if (!IsValidHuman(client)) + return Plugin_Handled; + + if (g_smStats == null || g_smStats.Size == 0) + { + PrintToChat(client, "%s Статистика пока пуста.", DUEL_PREFIX); + return Plugin_Handled; + } + + StringMapSnapshot snap = g_smStats.Snapshot(); + int count = snap.Length; + if (count > 64) count = 64; + + char[][] keys = new char[count][32]; + int[] wins = new int[count]; + + for (int i = 0; i < count; i++) + { + char steam[32]; + snap.GetKey(i, steam, sizeof(steam)); + strcopy(keys[i], 32, steam); + + DuelStats s; + g_smStats.GetArray(steam, s, sizeof(s)); + wins[i] = s.wins; + } + delete snap; + + // Простая сортировка пузырьком (топ-10 не критично по производительности) + for (int i = 0; i < count - 1; i++) + { + for (int j = 0; j < count - 1 - i; j++) + { + if (wins[j] < wins[j + 1]) + { + int tmpW = wins[j]; wins[j] = wins[j + 1]; wins[j + 1] = tmpW; + char tmpK[32]; strcopy(tmpK, 32, keys[j]); + strcopy(keys[j], 32, keys[j + 1]); + strcopy(keys[j + 1], 32, tmpK); + } + } + } + + Menu menu = new Menu(MenuHandler_DuelTop); + menu.SetTitle("Топ дуэлянтов сервера"); + + int show = (count < 10) ? count : 10; + for (int i = 0; i < show; i++) + { + DuelStats s; + g_smStats.GetArray(keys[i], s, sizeof(s)); + + char line[128]; + Format(line, sizeof(line), "%d. %s — %d побед / %d пораж.", + i + 1, s.name[0] == '\0' ? "Unknown" : s.name, s.wins, s.losses); + menu.AddItem("", line, ITEMDRAW_DISABLED); + } + + if (show == 0) + menu.AddItem("", "Никто ещё не сыграл ни одной дуэли", ITEMDRAW_DISABLED); + + menu.ExitButton = true; + menu.Display(client, 30); + return Plugin_Handled; +} + +public int MenuHandler_DuelTop(Menu menu, MenuAction action, int client, int item) +{ + if (action == MenuAction_End) + delete menu; + return 0; +}