From cf3c4705ce74dbe5a0921d860e32b2bd66bec697 Mon Sep 17 00:00:00 2001 From: svoboda200786 Date: Thu, 15 May 2025 16:20:25 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?server=5Fmodules=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая версия --- server_modules/data.js | 314 ++++---- server_modules/gameInstance.js | 1227 ++++++++++++++++++++++---------- server_modules/gameLogic.js | 599 +++++++++++----- server_modules/gameManager.js | 853 +++++++++++++++++----- 4 files changed, 2077 insertions(+), 916 deletions(-) diff --git a/server_modules/data.js b/server_modules/data.js index d92a0a6..26931b0 100644 --- a/server_modules/data.js +++ b/server_modules/data.js @@ -65,7 +65,7 @@ const playerAbilities = [ type: GAME_CONFIG.ACTION_TYPE_BUFF, duration: 4, descriptionFunction: (config) => `Восст. ${config.NATURE_STRENGTH_MANA_REGEN} маны при след. атаке (${4 - 1} хода)`, - isDelayed: true + isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки, а не сразу }, { id: GAME_CONFIG.ABILITY_ID_DEFENSE_AURA, @@ -73,7 +73,7 @@ const playerAbilities = [ cost: 15, type: GAME_CONFIG.ACTION_TYPE_BUFF, duration: 3, - grantsBlock: true, + grantsBlock: true, // Дает эффект блока на время действия descriptionFunction: (config) => `Снижает урон на ${config.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)` }, { @@ -81,7 +81,7 @@ const playerAbilities = [ name: 'Гипнотический взгляд', cost: 30, type: GAME_CONFIG.ACTION_TYPE_DISABLE, - effectDuration: 2, // Длительность безмолвия + effectDuration: 2, // Длительность безмолвия в ходах противника cooldown: 6, power: 5, // Урон в ход от взгляда description: 'Накладывает на противника полное безмолвие на 2 хода и наносит 5 урона каждый его ход. КД: 6 х.' @@ -91,11 +91,11 @@ const playerAbilities = [ name: 'Печать Слабости', cost: 30, type: GAME_CONFIG.ACTION_TYPE_DEBUFF, - effectDuration: 3, + effectDuration: 3, // Длительность дебаффа power: 10, // Количество ресурса противника, сжигаемое каждый ход cooldown: 5, // Описание теперь может адаптироваться к ресурсу оппонента - descriptionFunction: (config, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats.resourceName} противника каждый его ход в течение 3 ходов. КД: 5 х.` + descriptionFunction: (config, oppStats) => `Накладывает печать, сжигающую 10 ${oppStats.resourceName} противника каждый его ход в течение 3 ходов. КД: 5 х.'` } ]; @@ -107,8 +107,9 @@ const opponentAbilities = [ cost: 20, type: GAME_CONFIG.ACTION_TYPE_HEAL, power: 25, - successRate: 0.60, // Можно вынести в GAME_CONFIG + successRate: 0.60, // Шанс успеха description: 'Исцеляет ~25 HP с 60% шансом', + // Условие для AI: HP ниже порога condition: (opSt, plSt, currentGameState, config) => { return (opSt.currentHp / opSt.maxHp) * 100 < config.OPPONENT_HEAL_THRESHOLD_PERCENT; } @@ -118,34 +119,35 @@ const opponentAbilities = [ name: 'Эхо Безмолвия', cost: GAME_CONFIG.BALARD_SILENCE_ABILITY_COST, type: GAME_CONFIG.ACTION_TYPE_DISABLE, + // Описание с адаптацией descriptionFunction: (config) => `Шанс ${config.SILENCE_SUCCESS_RATE * 100}% заглушить случайное заклинание Елены на ${config.SILENCE_DURATION} х.`, + // Условие для AI: HP выше порога лечения, Елена не заглушена, не на спец. КД condition: (opSt, plSt, currentGameState, config) => { const hpPercent = (opSt.currentHp / opSt.maxHp) * 100; const isElenaAlreadySilenced = currentGameState?.player.disabledAbilities?.length > 0 || currentGameState?.player.activeEffects?.some(eff => eff.id.startsWith('playerSilencedOn_')); - // Условие для Баларда использовать безмолвие (только против Елены) - return hpPercent >= config.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && currentGameState.opponent.silenceCooldownTurns <= 0; + return hpPercent >= config.OPPONENT_HEAL_THRESHOLD_PERCENT && !isElenaAlreadySilenced && opSt.silenceCooldownTurns <= 0; }, - successRateFromConfig: 'SILENCE_SUCCESS_RATE', - durationFromConfig: 'SILENCE_DURATION', - internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN' + successRateFromConfig: 'SILENCE_SUCCESS_RATE', // Шанс берется из конфига + durationFromConfig: 'SILENCE_DURATION', // Длительность берется из конфига + internalCooldownFromConfig: 'BALARD_SILENCE_INTERNAL_COOLDOWN' // Спец. КД берется из конфига }, { id: GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN, name: 'Похищение Света', cost: 10, type: GAME_CONFIG.ACTION_TYPE_DRAIN, - powerManaDrain: 5, - powerDamage: 5, - powerHealthGainFactor: 1.0, + powerManaDrain: 5, // Сколько маны вытягивает + powerDamage: 5, // Сколько урона наносит дополнительно + powerHealthGainFactor: 1.0, // Множитель для расчета лечения от вытянутой маны description: `Вытягивает 5 Маны у Елены, наносит 5 урона и восстанавливает себе здоровье (100% от украденного).`, + // Условие для AI: У Елены достаточно маны, не на спец. КД condition: (opSt, plSt, currentGameState, config) => { const playerManaPercent = (plSt.currentResource / plSt.maxResource) * 100; const playerHasHighMana = playerManaPercent > (config.BALARD_MANA_DRAIN_HIGH_MANA_THRESHOLD || 60); - // Условие для Баларда использовать (только против Елены) - return playerHasHighMana && currentGameState.opponent.manaDrainCooldownTurns <= 0; + return playerHasHighMana && opSt.manaDrainCooldownTurns <= 0; }, - internalCooldownValue: 3 + internalCooldownValue: 3 // Спец. КД задается здесь } ]; @@ -175,7 +177,7 @@ const almagestAbilities = [ duration: 4, // Аналогично Силе Природы, но использует Темную Энергию descriptionFunction: (config) => `Восст. ${config.NATURE_STRENGTH_MANA_REGEN} Темной Энергии при след. атаке (${4 - 1} хода)`, - isDelayed: true + isDelayed: true // Этот эффект применяется ПОСЛЕ следующей атаки }, { id: GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE, @@ -183,7 +185,7 @@ const almagestAbilities = [ cost: 15, type: GAME_CONFIG.ACTION_TYPE_BUFF, duration: 3, - grantsBlock: true, + grantsBlock: true, // Дает эффект блока на время действия descriptionFunction: (config) => `Создает щит, снижающий урон на ${config.BLOCK_DAMAGE_REDUCTION * 100}% (${3} хода)` }, { @@ -209,154 +211,113 @@ const almagestAbilities = [ ]; -// --- Система Насмешек Елены (Переработанная) --- -// Ключи верхнего уровня: 'aiBalard' для игры против Баларда (AI) -// 'pvpAlmagest' для игры против Альмагест (PvP) -const elenaTauntSystem = { - aiBalard: { // Насмешки против Баларда (AI) - существующая логика - base: { - mercifulAttack: [ "Балард, прошу, остановись. Еще не поздно.", /* ... другие ... */ ], - mercifulCast: [ "Даже сейчас, я пытаюсь исцелить не только тело...", /* ... другие ... */ ], - dominating: { - creatorVsCreation: [ "Глина не спорит с гончаром, Балард! Прекрати жалкое сопротивление!", /* ... другие ... */ ], - betrayalOfLight: [ "Ты мог ходить в сиянии Света! Ты ИЗБРАЛ эту гниль! Получай возмездие!", /* ... другие ... */ ], - ingratitudeContempt: [ "Самый страшный грех - грех неблагодарности!", /* ... другие ... */ ], - unmakingThreats: [ "Я сотру тебя с лика этой земли, как досадную ошибку!", /* ... другие ... */ ] +// --- Система Насмешек --- +// Ключи верхнего уровня: characterKey того, кто произносит насмешку ('elena', 'almagest') +// Внутри каждого characterKey: ключи characterKey противника ('balard', 'almagest', 'elena') +// Внутри каждого противника: секции по триггерам (battleStart, selfCastAbility, onOpponentAction, etc.) + +const tauntSystem = { + elena: { // Насмешки Елены + balard: { // Против Баларда (AI) + // Триггер: Елена использует СВОЮ способность + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине." ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", "Пусть твой мрак сгорит!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", "Гармония природы со мной." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", "Моя вера - моя защита." ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", "Твой разум - в моей власти." ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", "Твоя сила угасает." ] + }, + // Триггер: Противник (Балард) совершает действие + onOpponentAction: { + [GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?" ], + [GAME_CONFIG.ABILITY_ID_BALARD_SILENCE]: { + success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!" ], + fail: [ "Твой шепот Тьмы слаб против Света Истины!" ] + }, + [GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!" ], + attackBlocked: [ "Твои удары тщетны перед щитом Порядка." ], // При блоке атаки Баларда + attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве." ] // При попадании атаки Баларда + }, + // Триггер: Базовая атака Елены + basicAttack: { + merciful: [ "Балард, прошу, остановись. Еще не поздно.", "Подумай о том, что потерял." ], + dominating: [ // Когда HP Баларда ниже порога PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT + "Глина не спорит с гончаром, Балард!", + "Ты ИЗБРАЛ эту гниль! Получай возмездие!", + "Самый страшный грех - грех неблагодарности!", + "Я сотру тебя с лика этой земли!" + ] + }, + // Триггер: Изменение состояния боя (например, начало, оппонент почти побежден) + onBattleState: { + start: [ "Балард, есть ли еще путь назад?" ], // Начало AI боя с Балардом + opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу." ] // Балард почти побежден } }, - onPlayerCast: { - [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", /* ... другие ... */ ], - [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", /* ... другие ... */ ], - [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", /* ... другие ... */ ], - [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", /* ... другие ... */ ], - [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри мне в глаза, Балард. И слушай тишину.", /* ... другие ... */ ], - [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя ярость иссякнет, как вода в песке, Балард!", /* ... другие ... */ ] - }, - onOpponentAction: { // Действия Баларда - [GAME_CONFIG.ABILITY_ID_BALARD_HEAL]: [ "Пытаешься отсрочить неизбежное жалкой темной силой?", /* ... другие ... */ ], - [GAME_CONFIG.ABILITY_ID_BALARD_SILENCE]: { - success: [ "(Сдавленный вздох)... Ничтожная попытка заглушить Слово!", /* ... другие ... */ ], - fail: [ "Твой шепот Тьмы слаб против Света Истины!", /* ... другие ... */ ] + almagest: { // Против Альмагест (PvP) + // Триггер: Елена использует СВОЮ способность + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Я исцеляюсь Светом, который ты отвергла.", "Жизнь восторжествует над твоей некромантией!", "Мое сияние не померкнет." ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Очищающий огонь для твоей тьмы!", "Почувствуй гнев праведного Света!", "Это пламя ярче твоих теней!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Природа дает мне силу, а тебе - лишь презрение.", "Я черпаю из источника жизни, ты - из могилы." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Мой щит отразит твою злобу.", "Свет - лучшая защита.", "Твои темные чары не пройдут!" ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "Смотри в глаза Истине, колдунья!", "Твои лживые речи умолкнут!", "Хватит прятаться за иллюзиями!" ], + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Твоя темная сила иссякнет!", "Я ослабляю твою связь с бездной!", "Почувствуй, как тает твоя энергия!" ] }, - [GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN]: [ "Ты питаешься Светом, как паразит?!", /* ... другие ... */ ], - attackBlocked: [ "Твои удары тщетны перед щитом Порядка.", /* ... другие ... */ ], - attackHits: [ "(Шипение боли)... Боль – лишь напоминание о твоем предательстве.", /* ... другие ... */ ] - }, - onBattleState: { - startMerciful: [ "Балард, есть ли еще путь назад?", /* ... другие ... */ ], - opponentNearDefeat: [ "Конец близок, Балард. Прими свою судьбу.", /* ... другие ... */ ] + // Триггер: Противник (Альмагест) совершает действие + onOpponentAction: { + [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Лечишь раны тьмой? Она лишь глубже проникнет в тебя.", "Твоя магия несет лишь порчу, даже исцеляя." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Твоя тень лишь царапает, не ранит.", "Слабый удар! Тьма делает тебя немощной." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Черпаешь силы из бездны? Она поглотит и тебя.", "Твое усиление - лишь агония искаженной энергии." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Щит из теней? Он рассыпется прахом!", "Твоя защита иллюзорна, как и твоя сила." ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "(Сдавленно) Твои ментальные атаки отвратительны!", "Тьма в моей голове... я вырвусь!" ], // Если Елена попадает под Раскол Разума Альмагест + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Истощаешь мою силу? Я восстановлю ее Светом!", "Твое проклятие слабо." ], // Если Елена попадает под Проклятие Увядания Альмагест + attackBlocked: [ "Твоя атака разбилась о мой щит Света!", "Предсказуемо и слабо, Альмагест." ], // При блоке атаки Альмагест + attackHits: [ "(Резкий вздох) Коснулась... Но Свет исцелит рану.", "Эта царапина - ничто!", "Ты заплатишь за это!" ] // При попадании атаки Альмагест + }, + // Триггер: Базовая атака Елены (PvP) + basicAttack: { + general: [ "Тьма не победит, Альмагест!", "Твои иллюзии рассеются перед Светом!", "Пока я стою, порядок будет восстановлен!" ] + }, + // Триггер: Изменение состояния боя + onBattleState: { + start: [ "Альмагест! Твоим темным делам пришел конец!", "Во имя Света, я остановлю тебя!", "Приготовься к битве, служительница тьмы!" ], // Начало PvP боя с Альмагест + opponentNearDefeat: [ "Твоя тьма иссякает, колдунья!", "Сдавайся, пока Свет не испепелил тебя!", "Конец твоим злодеяниям близок!" ] // Альмагест почти побеждена + } } }, - pvpAlmagest: { // Насмешки против Альмагест (PvP) - НОВАЯ ЛОГИКА - base: { - generalAttack: [ // Общие фразы при атаке - "Тьма не победит, Альмагест!", - "Твои иллюзии рассеются перед Светом!", - "Пока я стою, порядок будет восстановлен!", - "Не думай, что твои трюки остановят меня.", - "За каждую искаженную душу - получишь сторицей!", - "Сражайся честно, если осмелишься!", - "Даже тень боится истинного Света." - ], - generalCast: [ // Общие фразы при касте Еленой - "Сила Света на моей стороне!", - "Гармония мира укрепит меня.", - "Твоя магия хаоса бессильна здесь.", - "Истинная сила - в созидании, не в разрушении.", - "Я призываю чистую энергию!", - "Свет очистит эту землю от твоей скверны." - ] - }, - onPlayerCast: { // Реакция на конкретные касты Елены - [GAME_CONFIG.ABILITY_ID_HEAL]: [ - "Я исцеляюсь Светом, который ты отвергла.", - "Жизнь восторжествует над твоей некромантией!", - "Мое сияние не померкнет." - ], - [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ - "Очищающий огонь для твоей тьмы!", - "Почувствуй гнев праведного Света!", - "Это пламя ярче твоих теней!" - ], - [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ - "Природа дает мне силу, а тебе - лишь презрение.", - "Я черпаю из источника жизни, ты - из могилы.", - "Естественный порядок против твоего искажения." - ], - [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ - "Мой щит отразит твою злобу.", - "Свет - лучшая защита.", - "Твои темные чары не пройдут!" - ], - [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ - "Смотри в глаза Истине, колдунья!", - "Твои лживые речи умолкнут!", - "Хватит прятаться за иллюзиями!", - "Я вижу твою истинную, уродливую суть." - ], - [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ - "Твоя темная сила иссякнет!", - "Я ослабляю твою связь с бездной!", - "Почувствуй, как тает твоя энергия!" - ] - }, - onOpponentAction: { // Реакция на действия Альмагест - [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ - "Лечишь раны тьмой? Она лишь глубже проникнет в тебя.", - "Твоя магия несет лишь порчу, даже исцеляя.", - "Жалкие попытки отсрочить возмездие." - ], - [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ - "Твоя тень лишь царапает, не ранит.", - "Слабый удар! Тьма делает тебя немощной.", - "Думала, это причинит мне боль?", - "Свет поглотит твой мрак!" - ], - [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ - "Черпаешь силы из бездны? Она поглотит и тебя.", - "Твое усиление - лишь агония искаженной энергии.", - "Сколько бы ты ни копила тьму, Свет сильнее." - ], - [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ - "Щит из теней? Он рассыплется прахом!", - "Твоя защита иллюзорна, как и твоя сила.", - "Не спрячешься от Света за этой ширмой!" - ], - [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ // Если попала под Раскол Разума - "(Сдавленно) Твои ментальные атаки отвратительны!", - "Тьма в моей голове... я вырвусь!", - "Не сломить мой дух твоими фокусами!" - ], - [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ // Если попала под Проклятие Увядания - "Истощаешь мою силу? Я восстановлю ее Светом!", - "Твое проклятие слабо.", - "Чувствую холод... но он лишь закаляет волю!" - ], - attackBlocked: [ - "Твоя атака разбилась о мой щит Света!", - "Предсказуемо и слабо, Альмагест.", - "Моя защита безупречна." - ], - attackHits: [ - "(Резкий вздох) Коснулась... Но Свет исцелит рану.", - "Эта царапина - ничто!", - "Ты заплатишь за это!", - "Боль лишь укрепляет мою решимость!" - ] - }, - onBattleState: { - start: [ // Начало PvP боя - "Альмагест! Твоим темным делам пришел конец!", - "Во имя Света, я остановлю тебя!", - "Приготовься к битве, служительница тьмы!", - "Эта дуэль решит судьбу многих." - ], - opponentNearDefeat: [ // Альмагест почти побеждена - "Твоя тьма иссякает, колдунья!", - "Сдавайся, пока Свет не испепелил тебя!", - "Конец твоим злодеяниям близок!", - "Прими свое поражение!" - ] + almagest: { // Насмешки Альмагест + elena: { // Против Елены (PvP) + // Триггер: Альмагест использует СВОЮ способность + selfCastAbility: { + [GAME_CONFIG.ABILITY_ID_ALMAGEST_HEAL]: [ "Я питаюсь слабостью, Елена!", "Тьма дает мне силу!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DAMAGE]: [ "Почувствуй холод бездны!", "Твой Свет померкнет перед моей тенью!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK]: [ "Силы Бездны со мной!", "Моя тень становится гуще!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_DEFENSE]: [ "Мой щит выкован из самой тьмы!", "Попробуй пробить это, служительница Света!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DISABLE]: [ "Твой разум сломлен!", "Умолкни, Светлая!", "Я владею твоими мыслями!" ], + [GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF]: [ "Твоя сила тает!", "Почувствуй гниль!", "Я истощаю твой Свет!" ] + }, + // Триггер: Противник (Елена) совершает действие + onOpponentAction: { + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Исцеляешься? Твои раны слишком глубоки!" ], + [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Жалкое пламя! Мои тени поглотят его!" ], + [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сила земли? Смешно! Бездну ничто не остановит." ], + [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Твой щит из Света не спасет тебя от Тьмы!" ], + [GAME_CONFIG.ABILITY_ID_HYPNOTIC_GAZE]: [ "(Сдавленно, затем смех) Попытка управлять моим разумом? Жалко!", "Ты пытаешься заглянуть в Бездну?!" ], // Если Альмагест попадает под Гипнотический взгляд Елены + [GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS]: [ "Моя энергия вечна, дура!", "Это лишь раздражение!" ], // Если Альмагест попадает под Печать Слабости Елены + attackBlocked: [ "Твой блок не спасет тебя вечно, Елена!", "Это лишь задержка." ], // При блоке атаки Елены + attackHits: [ "Ха! Чувствуешь силу Тьмы?", "Это только начало!", "Слабость!" ] // При попадании атаки Елены + }, + // Триггер: Базовая атака Альмагест + basicAttack: { + general: [ "Почувствуй мою силу!", "Тени атакуют!", "Я наношу удар!" ] + }, + // Триггер: Изменение состояния боя + onBattleState: { + start: [ "Тысяча лет в заточении лишь усилили меня, Елена!", "Твой Свет скоро погаснет!", "Пора положить конец твоему господству!" ], // Начало PvP боя с Еленой + opponentNearDefeat: [ "Твой Свет гаснет!", "Ты побеждена!", "Бездне нужен твой дух!" ] // Елена почти побеждена + } } } }; @@ -374,10 +335,35 @@ const gameData = { opponentAbilities, // Балард almagestAbilities, // Альмагест - // Система насмешек (с разделением) - elenaTauntSystem + // Система насмешек (с разделением по персонажам и противникам) + tauntSystem // Переименовали для лучшей структуры }; +// --- Вспомогательные функции для использования ВНУТРИ data.js или модулей, которые его используют --- +// Эти функции НЕ являются частью экспортируемого объекта gameData, +// но могут быть вызваны внутри этого файла, например, в descriptionFunction +// Или их можно было бы экспортировать отдельно, если они нужны другим модулям, не имеющим gameData в аргументах. +// Но в текущей структуре gameLogic они не нужны, так как gameLogic получает gameData и имеет свои локальные хелперы. + +// function _getCharacterData(key) { +// if (!key) return null; +// switch (key) { +// case 'elena': return { baseStats: playerBaseStats, abilities: playerAbilities }; +// case 'balard': return { baseStats: opponentBaseStats, abilities: opponentAbilities }; +// case 'almagest': return { baseStats: almagestBaseStats, abilities: almagestAbilities }; +// default: console.error(`data.js::_getCharacterData: Unknown character key "${key}"`); return null; +// } +// } +// function _getCharacterBaseData(key) { +// const charData = _getCharacterData(key); +// return charData ? charData.baseStats : null; +// } +// function _getCharacterAbilities(key) { +// const charData = _getCharacterData(key); +// return charData ? charData.abilities : null; +// } + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = gameData; } diff --git a/server_modules/gameInstance.js b/server_modules/gameInstance.js index 7930756..2464d2b 100644 --- a/server_modules/gameInstance.js +++ b/server_modules/gameInstance.js @@ -1,192 +1,219 @@ // /server_modules/gameInstance.js -const GAME_CONFIG = require('./config'); -const gameData = require('./data'); -const serverGameLogic = require('./gameLogic'); +const { v4: uuidv4 } = require('uuid'); // Убедитесь, что uuidv4 установлен: npm install uuid +// Removed the self-import: const GameInstance = require('./gameInstance'); // Не нужно импортировать себя +const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient и данных персонажей +const GAME_CONFIG = require('./config'); // Нужен для GAME_CONFIG.PLAYER_ID и других констант +const serverGameLogic = require('./gameLogic'); // Подключаем игровую логику class GameInstance { - constructor(gameId, io, mode = 'ai') { + // ДОБАВЛЕН АРГУМЕНТ gameManager В КОНСТРУКТОР + constructor(gameId, io, mode = 'ai', gameManager) { this.id = gameId; - this.io = io; + this.io = io; // Ссылка на Socket.IO сервер для широковещательных рассылок this.mode = mode; // 'ai' или 'pvp' - this.players = {}; // { socket.id: { id: 'player'/'opponent', socket: socketObject, chosenCharacterKey?: 'elena'/'almagest' } } - this.playerSockets = {}; // { 'player': socketObject, 'opponent': socketObject } - для быстрого доступа к сокету по роли - this.playerCount = 0; - this.gameState = null; // Хранит текущее состояние игры (HP, ресурсы, эффекты, чей ход и т.д.) - this.aiOpponent = (mode === 'ai'); - this.logBuffer = []; // Буфер для сообщений лога боя - // this.restartVotes = new Set(); // Удалено, так как рестарт той же сессии убран + // players теперь { socket.id: { id: 'player'/'opponent', socket: socketObject, chosenCharacterKey?: 'elena'/'almagest', identifier: userId|socketId } } + this.players = {}; + // playerSockets { 'player': socketObject, 'opponent': socketObject } - для быстрого доступа к сокету по роли + // Этот маппинг хранит *текущие* сокеты. При переподключении ссылка обновляется в GameManager. + this.playerSockets = {}; + this.playerCount = 0; // Количество подключенных сокетов (активных игроков) в игре - // Ключи персонажей для текущей игры + this.gameState = null; // Хранит текущее состояние игры (HP, ресурсы, эффекты, чей ход и т.д.) + this.aiOpponent = (mode === 'ai'); // Флаг, является ли оппонент AI + + this.logBuffer = []; // Буфер для сообщений лога боя (ожидающих отправки клиентам) + + // Ключи персонажей для слотов 'player' и 'opponent' в текущей игре + // Определяются один раз при инициализации gameState this.playerCharacterKey = null; // Ключ персонажа в слоте 'player' (Елена или Альмагест) this.opponentCharacterKey = null; // Ключ персонажа в слоте 'opponent' (Балард, Елена или Альмагест) - this.ownerUserId = null; // userId создателя игры (важно для PvP ожидающих игр) + + this.ownerIdentifier = null; // Идентификатор создателя игры (userId или socketId) + + // СОХРАНЯЕМ ССЫЛКУ НА GAMEMANAGER + this.gameManager = gameManager; + if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { + console.error(`[Game ${this.id}] CRITICAL ERROR: GameInstance created without valid GameManager reference! Cleanup will fail.`); + // Если GameManager не передан или не имеет метода _cleanupGame, логика очистки не будет работать. + // В продакшене, возможно, стоит выбрасывать ошибку или завершать процесс. + } } - addPlayer(socket, chosenCharacterKey = 'elena') { + /** + * Добавляет игрока в игру. + * Вызывается GameManager при создании новой игры или присоединении второго игрока. + * @param {object} socket - Объект сокета игрока. + * @param {string} [chosenCharacterKey='elena'] - Выбранный игроком персонаж (только для первого игрока в PvP). + * @param {string|number} identifier - Идентификатор пользователя (userId или socketId). + * @returns {boolean} true, если игрок успешно добавлен, иначе false. + */ + addPlayer(socket, chosenCharacterKey = 'elena', identifier) { // <--- ДОБАВЛЕН identifier + // Проверка, не пытается ли сокет, который уже есть в этой игре, добавиться снова if (this.players[socket.id]) { - socket.emit('gameError', { message: 'Вы уже находитесь в этой игре.' }); - console.warn(`[Game ${this.id}] Игрок ${socket.id} попытался присоединиться к игре, в которой уже состоит.`); + console.warn(`[Game ${this.id}] Игрок ${identifier} (сокет ${socket.id}) попытался присоединиться к игре, в которой его сокет уже зарегистрирован.`); + // Возможно, это быстрый повторный запрос. + // Отправляем gameError и возвращаем false. + socket.emit('gameError', { message: 'Ваш сокет уже зарегистрирован в этой игре.' }); return false; } + // Проверка, не пытается ли пользователь, который уже есть в этой игре (но с другим сокетом), добавиться через addPlayer + // Этого не должно случаться, если GameManager.handleRequestGameState корректно обновляет сокет для существующего identifier. + // AddPlayer вызывается только для *нового* игрока в GameInstance. + const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); + if (existingPlayerByIdentifier) { + console.warn(`[Game ${this.id}] Игрок с идентификатором ${identifier} (${socket.id}) уже находится в игре под сокетом ${existingPlayerByIdentifier.socket.id}. Игнорируем добавление.`); + socket.emit('gameError', { message: 'Вы уже находитесь в этой игре под другим подключением.' }); + return false; + } + if (this.playerCount >= 2) { socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); return false; } - let assignedPlayerId; // 'player' или 'opponent' (технический ID слота) - let actualCharacterKey; // 'elena', 'almagest', 'balard' + let assignedPlayerId; // 'player' или 'opponent' (технический ID слота в рамках этой игры) + let actualCharacterKey; // 'elena', 'almagest' (balard только для AI-опонента) if (this.mode === 'ai') { - if (this.playerCount > 0) { + if (this.playerCount > 0) { // AI игра только для одного игрока socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); return false; } assignedPlayerId = GAME_CONFIG.PLAYER_ID; actualCharacterKey = 'elena'; // В AI режиме игрок всегда Елена - if (socket.userData?.userId) { - this.ownerUserId = socket.userData.userId; // Запоминаем создателя - } + this.ownerIdentifier = identifier; // Сохраняем идентификатор создателя AI игры + } else { // PvP режим if (this.playerCount === 0) { // Первый игрок в PvP assignedPlayerId = GAME_CONFIG.PLAYER_ID; actualCharacterKey = (chosenCharacterKey === 'almagest') ? 'almagest' : 'elena'; - if (socket.userData?.userId) { - this.ownerUserId = socket.userData.userId; // Запоминаем создателя - } - } else { // Второй игрок в PvP + this.ownerIdentifier = identifier; // Сохраняем идентификатор создателя PvP игры + } else { // Segundo jugador en PvP assignedPlayerId = GAME_CONFIG.OPPONENT_ID; - const firstPlayerInfo = Object.values(this.players)[0]; - // Второй игрок автоматически получает "зеркального" персонажа - actualCharacterKey = (firstPlayerInfo.chosenCharacterKey === 'elena') ? 'almagest' : 'elena'; + const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); // Находим первого игрока по его роли + // Segundo jugador recibe automáticamente un personaje "espejo" del primero. + actualCharacterKey = (firstPlayerInfo?.chosenCharacterKey === 'elena') ? 'almagest' : 'elena'; + // El identificador de ownerIdentifier sigue siendo el del creador de la partida (el primer jugador). } } + // Добавляем информацию об игроке в map players, ключом является socket.id this.players[socket.id] = { - id: assignedPlayerId, - socket: socket, - chosenCharacterKey: actualCharacterKey // Запоминаем ключ выбранного/назначенного персонажа + id: assignedPlayerId, // Технический ID слота ('player' или 'opponent') + socket: socket, // Объект сокета + chosenCharacterKey: actualCharacterKey, // Выбранный/назначенный персонаж + identifier: identifier // <--- ДОБАВЛЕНО: Идентификатор пользователя }; - this.playerSockets[assignedPlayerId] = socket; - this.playerCount++; - socket.join(this.id); // Присоединяем сокет к комнате игры + this.playerSockets[assignedPlayerId] = socket; // Связываем роль в игре с объектом сокета + this.playerCount++; // Увеличиваем количество активных сокетов + + socket.join(this.id); // Присоединяем сокет к комнате игры Socket.IO const characterData = this._getCharacterBaseData(actualCharacterKey); - console.log(`[Game ${this.id}] Игрок ${socket.userData?.username || socket.id} (userId: ${socket.userData?.userId || 'N/A'}) (${characterData?.name || 'Неизвестно'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Всего игроков: ${this.playerCount}. Owner: ${this.ownerUserId || 'N/A'}`); + console.log(`[Game ${this.id}] Игрок ${identifier} (сокет ${socket.id}) (${characterData?.name || 'Неизвестно'}) присоединился как ${assignedPlayerId} (персонаж: ${actualCharacterKey}). Всего игроков: ${this.playerCount}. Owner: ${this.ownerIdentifier || 'N/A'}`); - if (this.mode === 'pvp' && this.playerCount < 2) { - socket.emit('waitingForOpponent'); - } + // Логика старта игры перемещена в GameManager после вызова addPlayer. + // Логика уведомления "Ожидание оппонента" тоже перемещена в GameManager. - // Если игра готова к старту (2 игрока в PvP, или 1 в AI) - if ((this.mode === 'ai' && this.playerCount === 1) || (this.mode === 'pvp' && this.playerCount === 2)) { - this.initializeGame(); // Инициализируем состояние игры - if (this.gameState) { - this.startGame(); // Запускаем игру - } else { - console.error(`[Game ${this.id}] Не удалось запустить игру: gameState не был инициализирован.`); - // Ошибка должна была быть отправлена клиенту из initializeGame - } - } - return true; + return true; // Игрок успешно добавлен } - removePlayer(socketId) { - const playerInfo = this.players[socketId]; + /** + * Удаляет игрока из игры (например, при дисконнекте сокета). + * Вызывается GameManager при событии 'disconnect'. + * @param {string} socketId - ID сокета игрока, который отключился. + */ + removePlayer(socketId) { // <--- Принимает socketId + const playerInfo = this.players[socketId]; // Находим информацию об игроке по socketId if (playerInfo) { const playerRole = playerInfo.id; // 'player' or 'opponent' - let characterKeyOfLeavingPlayer = playerInfo.chosenCharacterKey; - const userIdOfLeavingPlayer = playerInfo.socket?.userData?.userId; - const usernameOfLeavingPlayer = playerInfo.socket?.userData?.username || socketId; - - // Для AI оппонента, у него нет записи в this.players, но его ключ 'balard' - if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) { // Если уходит игрок из AI игры - // AI оппонент не имеет 'chosenCharacterKey' в this.players, так как он не сокет - } else if (!characterKeyOfLeavingPlayer && this.gameState) { - // Фоллбэк, если ключ не был в playerInfo (маловероятно для реальных игроков) - characterKeyOfLeavingPlayer = (playerRole === GAME_CONFIG.PLAYER_ID) - ? this.gameState.player?.characterKey - : this.gameState.opponent?.characterKey; - } + const identifierOfLeavingPlayer = playerInfo.identifier; // Идентификатор уходящего игрока + const characterKeyOfLeavingPlayer = playerInfo.chosenCharacterKey; const characterData = this._getCharacterBaseData(characterKeyOfLeavingPlayer); - console.log(`[Game ${this.id}] Игрок ${usernameOfLeavingPlayer} (socket: ${socketId}, userId: ${userIdOfLeavingPlayer || 'N/A'}) (${characterData?.name || 'Неизвестно'}, роль: ${playerRole}, персонаж: ${characterKeyOfLeavingPlayer || 'N/A'}) покинул игру.`); + console.log(`[Game ${this.id}] Игрок ${identifierOfLeavingPlayer} (сокет: ${socketId}, роль: ${playerRole}, персонаж: ${characterKeyOfLeavingPlayer || 'N/A'}) покинул игру.`); + // Удаляем сокет из комнаты Socket.IO (если сокет объект еще валиден) + if (playerInfo.socket) { + // Проверяем, что это именно тот сокет, который отключился, + // чтобы не пытаться удалить сокет, если он уже был обновлен на новый при переподключении. + const actualSocket = this.io.sockets.sockets.get(socketId); + if (actualSocket && actualSocket.id === socketId) { // Убедимся, что это тот же сокет + actualSocket.leave(this.id); + } else if (playerInfo.socket.id === socketId) { // Если сокет объект в playerInfo совпадает с socketId + // Это может произойти, если Socket.IO уже удалил сокет из io.sockets.sockets, + // но ссылка в playerInfo еще существует. + // Попробуем выполнить leave на старом объекте сокета (может не работать, но безопасно) + try { playerInfo.socket.leave(this.id); } catch(e) { console.warn(`[Game ${this.id}] Error leaving room for old socket ${socketId}: ${e.message}`); } + } + } + + // Удаляем запись об игроке из map players по socketId + delete this.players[socketId]; + this.playerCount--; // Уменьшаем количество активных сокетов (сокетов, подключенных к игре) + + // Удаляем ссылку на сокет из playerSockets по роли, ТОЛЬКО если она указывала на отключившийся сокет if (this.playerSockets[playerRole] && this.playerSockets[playerRole].id === socketId) { delete this.playerSockets[playerRole]; } - delete this.players[socketId]; - this.playerCount--; - // Если создатель PvP игры вышел, и остался один игрок, обновляем ownerUserId - if (this.mode === 'pvp' && this.ownerUserId === userIdOfLeavingPlayer && this.playerCount === 1) { - const remainingPlayerSocketId = Object.keys(this.players)[0]; - const remainingPlayerSocket = this.players[remainingPlayerSocketId]?.socket; - this.ownerUserId = remainingPlayerSocket?.userData?.userId || null; // Новый владелец - userId оставшегося или null - console.log(`[Game ${this.id}] Owner left PvP game. New potential owner for pending game: ${this.ownerUserId || remainingPlayerSocketId}`); - } else if (this.playerCount === 0) { - this.ownerUserId = null; // Если игра пуста, нет владельца - } + // Логика завершения игры при дисконнекте полностью в GameManager.handleDisconnect. + // GameManager проверит playerCount после вызова removePlayer и примет решение. + // GameManager также управляет userIdentifierToGameId и pendingPvPGames. - // Если игра была активна, завершаем ее из-за дисконнекта - if (this.gameState && !this.gameState.isGameOver) { - this.endGameDueToDisconnect(playerRole, characterKeyOfLeavingPlayer || (playerRole === GAME_CONFIG.PLAYER_ID ? this.playerCharacterKey : this.opponentCharacterKey) ); - } - } - } - - endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey) { - if (this.gameState && !this.gameState.isGameOver) { - this.gameState.isGameOver = true; - const winnerRole = disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; - const disconnectedCharacterData = this._getCharacterBaseData(disconnectedCharacterKey); - const winnerCharacterKey = (winnerRole === GAME_CONFIG.PLAYER_ID) ? this.playerCharacterKey : this.opponentCharacterKey; - const winnerCharacterData = this._getCharacterBaseData(winnerCharacterKey); - - - this.addToLog(`Игрок ${disconnectedCharacterData?.name || 'Неизвестный'} покинул игру. Победа присуждается ${winnerCharacterData?.name || winnerRole}!`, GAME_CONFIG.LOG_TYPE_SYSTEM); - this.io.to(this.id).emit('opponentDisconnected', { disconnectedPlayerId: disconnectedPlayerRole, disconnectedCharacterName: disconnectedCharacterData?.name }); - - this.io.to(this.id).emit('gameOver', { - winnerId: winnerRole, - reason: 'opponent_disconnected', - finalGameState: this.gameState, - log: this.consumeLogBuffer() - }); + // Если игра была активна и еще не окончена, GameManager вызовет endGameDueToDisconnect - Moved + } else { + console.warn(`[Game ${this.id}] removePlayer called for unknown socketId ${socketId}.`); } } + /** + * Инициализирует или перезапускает состояние игры. + * Вызывается GameManager, когда игра полностью готова (PvP 2 игрока, AI 1 игрок) ИЛИ + * для частичной инициализации PvP игры с 1 игроком. + * @returns {boolean} true, если оба бойца определены и gameState создан корректно, иначе false. + */ initializeGame() { - console.log(`[Game ${this.id}] Initializing game state for (re)start... Mode: ${this.mode}`); + console.log(`[Game ${this.id}] Initializing game state. Mode: ${this.mode}. Current PlayerCount: ${this.playerCount}.`); - if (this.mode === 'ai') { - this.playerCharacterKey = 'elena'; // Игрок в AI всегда Елена - this.opponentCharacterKey = 'balard'; // AI всегда Балард - } else { // pvp - const playerSocketInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - const opponentSocketInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); + // Определяем ключи персонажей для слотов 'player' и 'opponent' в этой игре + // В AI режиме всегда Елена vs Балард + if (this.mode === 'ai' && this.playerCount === 1) { + this.playerCharacterKey = 'elena'; + this.opponentCharacterKey = 'balard'; + } else if (this.mode === 'pvp' && this.playerCount === 2) { // PvP режим с 2 игроками + // Находим информацию об игроках по их ролям + const player1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + const player2Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); - this.playerCharacterKey = playerSocketInfo?.chosenCharacterKey || 'elena'; // Фоллбэк, если что-то пошло не так + // Первый игрок (в слоте PLAYER_ID) сохраняет свой выбранный персонаж + this.playerCharacterKey = player1Info?.chosenCharacterKey || 'elena'; // Фоллбэк - if (this.playerCount === 2 && opponentSocketInfo) { - this.opponentCharacterKey = opponentSocketInfo.chosenCharacterKey; - // Дополнительная проверка, чтобы персонажи были разными, если вдруг оба выбрали одного - if (this.playerCharacterKey === this.opponentCharacterKey) { - this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; - // Обновляем ключ у второго игрока, если он был изменен - if (opponentSocketInfo.chosenCharacterKey !== this.opponentCharacterKey) { - opponentSocketInfo.chosenCharacterKey = this.opponentCharacterKey; - console.warn(`[Game ${this.id}] PvP character conflict resolved. Opponent in slot '${GAME_CONFIG.OPPONENT_ID}' is now ${this.opponentCharacterKey}.`); - } - } - } else if (this.playerCount === 1) { // Только один игрок в PvP, оппонент еще не определен - this.opponentCharacterKey = null; - } else { // Неожиданная ситуация - console.error(`[Game ${this.id}] Unexpected playerCount (${this.playerCount}) or missing socketInfo during PvP character key assignment.`); - this.opponentCharacterKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; // Аварийный фоллбэк + // Второй игрок (в слоте OPPONENT_ID) имеет зеркального персонажа + const expectedOpponentKey = (this.playerCharacterKey === 'elena') ? 'almagest' : 'elena'; + // Убеждаемся, что у второго игрока действительно назначен этот персонаж. + // В addPlayer мы уже стараемся назначить правильно, но тут финальное подтверждение. + if (player2Info && player2Info.chosenCharacterKey !== expectedOpponentKey) { + console.warn(`[Game ${this.id}] initializeGame: Expected opponent character ${expectedOpponentKey} but player2Info had ${player2Info.chosenCharacterKey}.`); + // Не меняем chosenCharacterKey здесь, это данные игрока. + // Просто используем expectedOpponentKey для определения персонажа в слоте gameState. } + this.opponentCharacterKey = expectedOpponentKey; // Устанавливаем ключ для слота + + } else if (this.mode === 'pvp' && this.playerCount === 1) { + // PvP игра ожидает второго игрока. Инициализируем gameState только для Player 1. + const player1Info = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + this.playerCharacterKey = player1Info?.chosenCharacterKey || 'elena'; + this.opponentCharacterKey = null; // Оппонент еще не определен + + } else { + console.error(`[Game ${this.id}] Unexpected state for initialization! Mode: ${this.mode}, PlayerCount: ${this.playerCount}. Cannot initialize gameState.`); + this.gameState = null; // Не создаем gameState + return false; // <-- Возвращаем false при ошибке } - console.log(`[Game ${this.id}] Finalizing characters - Player Slot ('${GAME_CONFIG.PLAYER_ID}'): ${this.playerCharacterKey}, Opponent Slot ('${GAME_CONFIG.OPPONENT_ID}'): ${this.opponentCharacterKey || 'N/A (Waiting)'}`); + + console.log(`[Game ${this.id}] Finalizing characters for gameState - Player Slot ('${GAME_CONFIG.PLAYER_ID}'): ${this.playerCharacterKey}, Opponent Slot ('${GAME_CONFIG.OPPONENT_ID}'): ${this.opponentCharacterKey || 'N/A (Waiting)'}`); const playerBase = this._getCharacterBaseData(this.playerCharacterKey); const playerAbilities = this._getCharacterAbilities(this.playerCharacterKey); @@ -194,65 +221,94 @@ class GameInstance { let opponentAbilities = null; // Загружаем данные оппонента, только если он определен (т.е. PvP игра с 2 игроками или AI игра) - if (this.opponentCharacterKey) { + const isOpponentDefined = !!this.opponentCharacterKey; + if (isOpponentDefined) { opponentBase = this._getCharacterBaseData(this.opponentCharacterKey); opponentAbilities = this._getCharacterAbilities(this.opponentCharacterKey); } - // Проверяем, готовы ли мы к созданию полного игрового состояния - const isReadyForFullGameState = (this.mode === 'ai') || (this.mode === 'pvp' && this.playerCount === 2 && opponentBase && opponentAbilities); - - if (!playerBase || !playerAbilities || (!isReadyForFullGameState && !(this.mode === 'pvp' && this.playerCount === 1))) { - console.error(`[Game ${this.id}] CRITICAL ERROR: Failed to load necessary character data for initialization! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}, PlayerCount: ${this.playerCount}, Mode: ${this.mode}`); + // Проверяем наличие данных для создания базового gameState + if (!playerBase || !playerAbilities) { + console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Failed to load player character data! PlayerKey: ${this.playerCharacterKey}`); this.logBuffer = []; // Очищаем лог - this.addToLog('Критическая ошибка сервера при инициализации персонажей!', GAME_CONFIG.LOG_TYPE_SYSTEM); - // Уведомляем игроков в комнате об ошибке - this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры. Не удалось загрузить данные персонажей.' }); + this.addToLog('Критическая ошибка сервера при инициализации персонажа игрока!', GAME_CONFIG.LOG_TYPE_SYSTEM); + // Уведомляем игроков в комнате об ошибке (если есть) + Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры. Не удалось загрузить данные персонажа игрока.' })); this.gameState = null; // Не создаем gameState - return; + return false; // <-- Возвращаем false при ошибке } - // Создаем gameState + // Проверяем наличие данных оппонента, если он должен быть определен + if (isOpponentDefined && (!opponentBase || !opponentAbilities)) { + console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Failed to load opponent character data! OpponentKey: ${this.opponentCharacterKey}`); + this.logBuffer = []; + this.addToLog('Критическая ошибка сервера при инициализации персонажа оппонента!', GAME_CONFIG.LOG_TYPE_SYSTEM); + Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера при инициализации игры. Не удалось загрузить данные персонажа оппонента.' })); + this.gameState = null; + return false; // <-- Возвращаем false при ошибке + } + + // Проверка, если оппонент определен, что у него есть maxHp > 0 (для startGame) + if (isOpponentDefined && (!opponentBase.maxHp || opponentBase.maxHp <= 0)) { + console.error(`[Game ${this.id}] CRITICAL ERROR: initializeGame - Opponent has invalid maxHp (${opponentBase.maxHp}) for key ${this.opponentCharacterKey}.`); + this.logBuffer = []; + this.addToLog('Критическая ошибка сервера: некорректные данные оппонента!', GAME_CONFIG.LOG_TYPE_SYSTEM); + Object.values(this.players).forEach(p => p.socket.emit('gameError', { message: 'Критическая ошибка сервера: некорректные данные персонажа оппонента.' })); + this.gameState = null; + return false; // <-- Возвращаем false при ошибке + } + + + // Создаем начальное gameState this.gameState = { - player: { + player: { // Состояние игрока в слоте 'player' id: GAME_CONFIG.PLAYER_ID, characterKey: this.playerCharacterKey, name: playerBase.name, currentHp: playerBase.maxHp, maxHp: playerBase.maxHp, currentResource: playerBase.maxResource, maxResource: playerBase.maxResource, resourceName: playerBase.resourceName, attackPower: playerBase.attackPower, isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {} }, - opponent: { // Данные оппонента, если он есть, иначе плейсхолдеры + opponent: { // Состояние оппонента в слоте 'opponent'. Если оппонент еще не определен (PvP ожидание), будут плейсхолдеры. id: GAME_CONFIG.OPPONENT_ID, characterKey: this.opponentCharacterKey, name: opponentBase?.name || 'Ожидание игрока...', - currentHp: opponentBase?.maxHp || 1, maxHp: opponentBase?.maxHp || 1, + currentHp: opponentBase?.maxHp || 1, maxHp: opponentBase?.maxHp || 1, // HP > 0, чтобы не триггерить победу сразу, если оппонент не определен currentResource: opponentBase?.maxResource || 0, maxResource: opponentBase?.maxResource || 0, - resourceName: opponentBase?.resourceName || 'Неизвестно', attackPower: opponentBase?.attackPower || 0, - isBlocking: false, activeEffects: [], - // Специальные кулдауны для Баларда (AI) - silenceCooldownTurns: this.opponentCharacterKey === 'balard' ? 0 : undefined, - manaDrainCooldownTurns: this.opponentCharacterKey === 'balard' ? 0 : undefined, - abilityCooldowns: {} + resourceName: opponentBase?.resourceName || 'Ресурс', attackPower: opponentBase?.attackPower || 0, + isBlocking: false, activeEffects: [], disabledAbilities: [], abilityCooldowns: {}, + // Специальные кулдауны для Баларда (AI) - undefined для других персонажей + silenceCooldownTurns: (this.opponentCharacterKey === 'balard') ? 0 : undefined, + manaDrainCooldownTurns: (this.opponentCharacterKey === 'balard') ? 0 : undefined, }, - isPlayerTurn: Math.random() < 0.5, // Случайный первый ход + // Случайный первый ход только если оба игрока (бойца) определены (т.е. игра готова к старту) + isPlayerTurn: isOpponentDefined ? Math.random() < 0.5 : true, // Если ожидаем, всегда ход первого игрока (кто создал) isGameOver: false, turnNumber: 1, gameMode: this.mode }; - // Инициализация кулдаунов способностей - playerAbilities.forEach(ability => { - if (typeof ability.cooldown === 'number' && ability.cooldown > 0) { - this.gameState.player.abilityCooldowns[ability.id] = 0; - } - }); - if (opponentAbilities) { + // Инициализация кулдаунов способностей для обоих бойцов (если они определены) + // Для игрока (player slot) + if (playerAbilities) { + playerAbilities.forEach(ability => { + if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { + this.gameState.player.abilityCooldowns[ability.id] = 0; + } + }); + } else { + console.error(`[Game ${this.id}] Cannot initialize player abilities cooldowns: playerAbilities is null.`); + } + + // Для оппонента (opponent slot), только если он определен + if (isOpponentDefined && opponentAbilities) { opponentAbilities.forEach(ability => { let cd = 0; - if (ability.cooldown) cd = ability.cooldown; - else if (this.opponentCharacterKey === 'balard') { // Специальные внутренние КД для AI Баларда - if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) { - cd = GAME_CONFIG[ability.internalCooldownFromConfig]; - } else if (typeof ability.internalCooldownValue === 'number') { + if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { + cd = ability.cooldown; + } + if (this.opponentCharacterKey === 'balard') { // Специальные КД для Баларда + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { + cd = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; + } else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { cd = ability.internalCooldownValue; } } @@ -260,377 +316,798 @@ class GameInstance { this.gameState.opponent.abilityCooldowns[ability.id] = 0; } }); + } else if (isOpponentDefined) { + console.warn(`[Game ${this.id}] Cannot initialize opponent abilities cooldowns: opponentAbilities is null for key ${this.opponentCharacterKey}.`); } - const isRestart = this.logBuffer.length > 0 && isReadyForFullGameState; // Проверяем, был ли лог до этого (признак рестарта) - this.logBuffer = []; // Очищаем лог перед новой игрой/рестартом - if (isReadyForFullGameState) { // Лог о начале битвы только если игра полностью готова + + // Добавляем начальное сообщение в лог и стартовую насмешку, только если игра полностью готова к старту (оба бойца определены) + if (isOpponentDefined) { // Проверяем, определен ли оппонент (значит, игра полностью готова) + const isRestart = this.logBuffer.length > 0; // Сейчас этот буфер всегда пустой при инициализации новой игры + this.logBuffer = []; // Очищаем лог перед новой игрой + this.addToLog(isRestart ? '⚔️ Игра перезапущена! ⚔️' : '⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); + + // Добавляем стартовую насмешку игрока (в слоте 'player'), если она есть + const playerCharKey = this.gameState.player.characterKey; + if (playerCharKey === 'elena' || playerCharKey === 'almagest') { + // getRandomTaunt теперь принимает gameDataForLogic и gameState + const startTaunt = serverGameLogic.getRandomTaunt(playerCharKey, 'battleStart', {}, GAME_CONFIG, gameData, this.gameState); + if (startTaunt !== "(Молчание)") this.addToLog(`${this.gameState.player.name}: "${startTaunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + } } - console.log(`[Game ${this.id}] Game state initialized. isGameOver: ${this.gameState.isGameOver}. First turn: ${this.gameState.isPlayerTurn ? this.gameState.player.name : (this.gameState.opponent?.name || 'Оппонент')}`); + // Если оппонент не определен (PvP ожидание),gameState инициализируется частично, но не считается готовым к старту. + // LogBuffer не очищается, waitingForOpponent отправлено клиенту. + console.log(`[Game ${this.id}] Game state initialized. isGameOver: ${this.gameState?.isGameOver}. First turn (if ready): ${this.gameState?.isPlayerTurn ? this.gameState?.player?.name : (this.gameState?.opponent?.name || 'Оппонент')}. Opponent Defined (Ready for Start): ${isOpponentDefined}`); + + // Возвращаем флаг, готово ли gameState к началу игры (т.е. определены ли оба бойца) + return isOpponentDefined; // <-- Важно: возвращаем, готова ли игра к старту } + /** + * Запускает игру (отправляет начальное состояние клиентам и начинает первый ход). + * Вызывается GameManager после успешной инициализации gameState и наличия 2 игроков (PvP) или 1 (AI), + * и если initializeGame вернул true. + */ startGame() { - // Проверяем, что игра полностью готова к запуску (оба игрока есть и gameState инициализирован) - if (!this.gameState || !this.gameState.player || !this.gameState.opponent || !this.opponentCharacterKey || this.gameState.opponent.name === 'Ожидание игрока...') { - if (this.mode === 'pvp' && this.playerCount === 1 && !this.opponentCharacterKey) { - console.log(`[Game ${this.id}] startGame: PvP игра ожидает второго игрока.`); - } else if (!this.gameState) { - console.error(`[Game ${this.id}] Game cannot start: gameState is null.`); - } else { - console.warn(`[Game ${this.id}] Game not fully ready to start. OpponentKey: ${this.opponentCharacterKey}, OpponentName: ${this.gameState.opponent?.name}, PlayerCount: ${this.playerCount}`); - } + // Проверяем, что игра полностью готова к запуску (gameState инициализирован с обоими персонажами) + if (!this.gameState || !this.gameState.player || !this.gameState.opponent || !this.opponentCharacterKey || this.gameState.opponent.name === 'Ожидание игрока...' || !this.gameState.opponent.maxHp || this.gameState.opponent.maxHp <= 0) { + console.error(`[Game ${this.id}] startGame: Game state is not fully ready for start. Aborting.`); + // initializeGame должен был вернуть false и добавить ошибку в лог. + // GameManager должен был вызвать cleanupGame при ошибке initializeGame. return; } + // Убеждаемся, что у нас есть 1 или 2 игрока (сокета) - GameManager должен гарантировать это перед вызовом startGame + if (this.playerCount === 0 || (this.mode === 'pvp' && this.playerCount === 1)) { + console.warn(`[Game ${this.id}] startGame called with insufficient players (${this.playerCount}). Mode: ${this.mode}. Aborting start.`); + return; + } + + console.log(`[Game ${this.id}] Starting game. Broadcasting 'gameStarted' to players. isGameOver: ${this.gameState.isGameOver}`); - const playerCharData = this._getCharacterData(this.playerCharacterKey); - const opponentCharData = this._getCharacterData(this.opponentCharacterKey); + // Получаем данные персонажей для отправки клиентам + const playerCharDataForSlotPlayer = this._getCharacterData(this.playerCharacterKey); + const opponentCharDataForSlotOpponent = this._getCharacterData(this.opponentCharacterKey); - if (!playerCharData || !opponentCharData) { + if (!playerCharDataForSlotPlayer || !opponentCharDataForSlotOpponent) { console.error(`[Game ${this.id}] CRITICAL ERROR: startGame - Failed to load character data! PlayerKey: ${this.playerCharacterKey}, OpponentKey: ${this.opponentCharacterKey}`); this.io.to(this.id).emit('gameError', { message: 'Критическая ошибка сервера при старте игры (не удалось загрузить данные персонажей).' }); + // Критическая ошибка, игра должна быть очищена + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, 'start_data_load_failed'); + } return; } - // Отправляем каждому игроку его персональные данные для игры + // Отправляем каждому игроку его персональные данные для игры и начальное состояние Object.values(this.players).forEach(playerInfo => { - let dataForThisClient; - if (playerInfo.id === GAME_CONFIG.PLAYER_ID) { // Этот клиент играет за слот 'player' - dataForThisClient = { - gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, - playerBaseStats: playerCharData.baseStats, opponentBaseStats: opponentCharData.baseStats, - playerAbilities: playerCharData.abilities, opponentAbilities: opponentCharData.abilities, - log: this.consumeLogBuffer(), // Первый игрок получает весь накопленный лог - clientConfig: { ...GAME_CONFIG } // Копия конфига для клиента - }; - } else { // Этот клиент играет за слот 'opponent' - dataForThisClient = { - gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, - // Меняем местами статы и абилки, чтобы клиент видел себя как 'player', а противника как 'opponent' - playerBaseStats: opponentCharData.baseStats, opponentBaseStats: playerCharData.baseStats, - playerAbilities: opponentCharData.abilities, opponentAbilities: playerCharData.abilities, - log: [], // Второй игрок не получает стартовый лог, чтобы избежать дублирования - clientConfig: { ...GAME_CONFIG } - }; + // Проверяем, что сокет игрока еще подключен + if (playerInfo.socket && playerInfo.socket.connected) { + let dataForThisClient; + // playerInfo.id - это технический ID слота этого конкретного клиента в рамках игры ('player' или 'opponent') + if (playerInfo.id === GAME_CONFIG.PLAYER_ID) { // Этот клиент играет за слот 'player' + dataForThisClient = { + gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, + playerBaseStats: playerCharDataForSlotPlayer.baseStats, opponentBaseStats: opponentCharDataForSlotOpponent.baseStats, + playerAbilities: playerCharDataForSlotPlayer.abilities, opponentAbilities: opponentCharDataForSlotOpponent.abilities, + log: this.consumeLogBuffer(), // Игрок в слоте player получает весь накопленный лог + clientConfig: { ...GAME_CONFIG } // Копия конфига для клиента + }; + } else { // Этот клиент играет за слот 'opponent' (только в PvP) + dataForThisClient = { + gameId: this.id, yourPlayerId: playerInfo.id, initialGameState: this.gameState, + // Меняем местами статы и абилки, чтобы клиент видел себя как 'player', а противника как 'opponent' + playerBaseStats: opponentCharDataForSlotOpponent.baseStats, opponentBaseStats: playerCharDataForSlotPlayer.baseStats, + playerAbilities: opponentCharDataForSlotOpponent.abilities, opponentAbilities: playerCharDataForSlotPlayer.abilities, + log: this.consumeLogBuffer(), // Второй игрок тоже получает весь накопленный лог + clientConfig: { ...GAME_CONFIG } + }; + } + playerInfo.socket.emit('gameStarted', dataForThisClient); + console.log(`[Game ${this.id}] Sent gameStarted to ${playerInfo.identifier} (socket ${playerInfo.socket.id}).`); + } else { + console.warn(`[Game ${this.id}] Player ${playerInfo.identifier} (socket ${playerInfo.socket?.id}) is disconnected. Cannot send gameStarted.`); + // Если один из игроков отключен в момент старта PvP игры, она должна быть завершена. + // GameManager.handleDisconnect уже должен был вызвать endGameDueToDisconnect. } - playerInfo.socket.emit('gameStarted', dataForThisClient); }); - const firstTurnName = this.gameState.isPlayerTurn ? this.gameState.player.name : this.gameState.opponent.name; - this.addToLog(`--- ${firstTurnName} ходит первым! (Ход ${this.gameState.turnNumber}) ---`, GAME_CONFIG.LOG_TYPE_TURN); - this.broadcastGameStateUpdate(); // Отправляем начальное состояние и лог + // Добавляем лог о начале хода после отправки gameStarted, чтобы лог был актуальным + const firstTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; + this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${firstTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + // Отправляем этот лог всем клиентам + this.broadcastLogUpdate(); - // Если ход AI, запускаем его логику - if (!this.gameState.isPlayerTurn) { - if (this.aiOpponent && this.opponentCharacterKey === 'balard') { - setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); - } else { // PvP, ход второго игрока - this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.OPPONENT_ID }); - } - } else { // Ход первого игрока (реального) - this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.PLAYER_ID }); + + // Если ход AI, запускаем его логику с небольшой задержкой + // AI Балард всегда в слоте 'opponent' в AI режиме, и его characterKey === 'balard' + if (!this.gameState.isPlayerTurn && this.aiOpponent && this.opponentCharacterKey === 'balard') { + console.log(`[Game ${this.id}] AI (Балард) ходит первым. Запускаем AI turn.`); + // Небольшая задержка для старта AI хода, чтобы клиент успел отрисовать + setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN || 1200); + } else { // Ход реального игрока (первого или второго в PvP) + // Игрок, чей ход, получит индикатор хода и активные кнопки через gameStateUpdate в клиентском ui.js + console.log(`[Game ${this.id}] Ход реального игрока ${firstTurnActorState.name} (роль: ${firstTurnActorState.id}).`); } } - // Метод handleVoteRestart удален + /** + * Обрабатывает действие игрока, полученное от клиента. + * Вызывается GameManager при событии 'playerAction'. + * @param {string} requestingSocketId - ID сокета игрока, запросившего действие. + * @param {object} actionData - Данные о действии ({ actionType: 'attack' | 'ability', abilityId?: string }). + */ processPlayerAction(requestingSocketId, actionData) { - if (!this.gameState || this.gameState.isGameOver) return; - const actingPlayerInfo = this.players[requestingSocketId]; - if (!actingPlayerInfo) { console.error(`[Game ${this.id}] Action from unknown socket ${requestingSocketId}`); return; } + // Проверяем, что игра активна и не окончена + if (!this.gameState || this.gameState.isGameOver) { + const playerSocket = this.io.sockets.sockets.get(requestingSocketId); + if (playerSocket) playerSocket.emit('gameError', { message: 'Игра уже завершена или неактивна.' }); + return; + } - const actingPlayerRole = actingPlayerInfo.id; // 'player' или 'opponent' + // Находим информацию об игроке по socketId (используем текущий socketId, т.к. он пришел в запросе) + const actingPlayerInfo = this.players[requestingSocketId]; + if (!actingPlayerInfo) { + // Этого не должно происходить, если GameManager корректно ищет игру по идентификатору и передает + // текущий socketId. Но если произошло, возможно, сокет отправил действие после удаления из players. + console.error(`[Game ${this.id}] Action from socket ${requestingSocketId} not found in players map.`); + const playerSocket = this.io.sockets.sockets.get(requestingSocketId); + if (playerSocket && playerSocket.connected) playerSocket.disconnect(true); // Отключаем подозрительный сокет + return; + } + + const actingPlayerRole = actingPlayerInfo.id; // 'player' или 'opponent' (технический ID слота) const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || (!this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.OPPONENT_ID); if (!isCorrectTurn) { - actingPlayerInfo.socket.emit('gameError', { message: "Сейчас не ваш ход!" }); - return; + // Неправильный ход - просто игнорируем действие. Клиентский UI должен предотвращать такое. + // actingPlayerInfo.socket.emit('gameError', { message: "Сейчас не ваш ход!" }); // Можно отправлять ошибку, но это спамит лог + console.warn(`[Game ${this.id}] Action from ${actingPlayerInfo.identifier} (socket ${requestingSocketId}): Not their turn.`); + return; // Игнорируем действие } - const attackerState = this.gameState[actingPlayerRole]; + const attackerState = this.gameState[actingPlayerRole]; // Состояние того, кто ходит (в gameState по роли) const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; - const defenderState = this.gameState[defenderRole]; + const defenderState = this.gameState[defenderRole]; // Состояние противника (в gameState по роли) + // Получаем базовые данные персонажей для логики const attackerData = this._getCharacterData(attackerState.characterKey); const defenderData = this._getCharacterData(defenderState.characterKey); if (!attackerData || !defenderData) { + console.error(`[Game ${this.id}] CRITICAL ERROR: processPlayerAction - Failed to load character data! AttackerKey: ${attackerState.characterKey}, DefenderKey: ${defenderState.characterKey}`); this.addToLog('Критическая ошибка сервера при обработке действия (не найдены данные персонажа)!', GAME_CONFIG.LOG_TYPE_SYSTEM); - this.broadcastLogUpdate(); return; + this.broadcastLogUpdate(); // Уведомляем клиентов об ошибке через лог + // Критическая ошибка, игра должна быть очищена + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, 'action_data_load_failed'); + } + return; } - let actionValid = true; // Флаг валидности действия + let actionValid = true; // Флаг валидности действия (логической, не очередности хода) - // Обработка атаки + // --- Обработка базовой атаки --- if (actionData.actionType === 'attack') { + // Добавляем насмешку при базовой атаке (если есть такие для говорящего) + // Насмешки при атаке могут быть специфичны для говорящего и противника + // getRandomTaunt теперь принимает gameDataForLogic и gameState + const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'basicAttack', {}, GAME_CONFIG, gameData, this.gameState); + if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + + // Выполняем логику атаки через gameLogic + // performAttack теперь принимает gameDataForLogic и gameState serverGameLogic.performAttack( attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData ); + // Логика для "Силы Природы" и аналогов - бафф применяется после атаки - const attackBuffAbilityId = attackerState.characterKey === 'elena' ? GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH - : (attackerState.characterKey === 'almagest' ? GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK : null); - if (attackBuffAbilityId) { - const attackBuffEffect = attackerState.activeEffects.find(eff => eff.id === attackBuffAbilityId); - if (attackBuffEffect && !attackBuffEffect.justCast) { // Эффект должен быть активен и не только что применен - const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource); - if (actualRegen > 0) { - attackerState.currentResource += actualRegen; - this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${attackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); - } - // Не удаляем эффект, если он многоразовый. Если одноразовый - удалить тут. - // В текущей реализации Сила Природы имеет duration, поэтому управляется через processEffects. + // Проверяем, есть ли активный "отложенный" бафф (isDelayed) на атакующем + const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); + + if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) { // Если эффект активен и не только что наложен в этом ходу + const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerData.baseStats.maxResource - attackerState.currentResource); + if (actualRegen > 0) { + attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); + this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); } } - // Обработка способности + + // --- Обработка использования способности --- } else if (actionData.actionType === 'ability' && actionData.abilityId) { const ability = attackerData.abilities.find(ab => ab.id === actionData.abilityId); - if (!ability) { actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); return; } - - // Проверки валидности использования способности - if (attackerState.currentResource < ability.cost) { this.addToLog(`${attackerState.name} пытается применить "${ability.name}", но не хватает ${attackerState.resourceName}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } - if (actionValid && attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0) { this.addToLog(`"${ability.name}" еще на перезарядке (${attackerState.abilityCooldowns[ability.id]} х.).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } - // Специальные КД для Баларда - if (actionValid && attackerState.characterKey === 'balard') { - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns > 0) { this.addToLog(`"${ability.name}" еще не готова (спец. КД).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns > 0) { this.addToLog(`"${ability.name}" еще не готова (спец. КД).`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (!ability) { + actingPlayerInfo.socket.emit('gameError', { message: "Неизвестная способность." }); + console.warn(`[Game ${this.id}] Игрок ${actingPlayerInfo.identifier} (сокет ${requestingSocketId}) попытался использовать неизвестную способность ID: ${actionData.abilityId}.`); + return; // Неизвестная способность } - // Нельзя кастовать бафф, если он уже активен - if (actionValid && ability.type === GAME_CONFIG.ACTION_TYPE_BUFF && attackerState.activeEffects.some(e => e.id === ability.id)) { this.addToLog(`Эффект "${ability.name}" уже активен!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + + // Проверки валидности использования способности (ресурс, КД, безмолвие и т.д.) + // Эти проверки дублируют клиентские, но нужны на сервере для безопасности + const hasEnoughResource = attackerState.currentResource >= ability.cost; + const isOnCooldown = (attackerState.abilityCooldowns?.[ability.id] || 0) > 0; + const isCasterFullySilenced = attackerState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); + const isAbilitySpecificallySilenced = attackerState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0); + const isSilenced = isCasterFullySilenced || isAbilitySpecificallySilenced; + + // Специальные КД для Баларда (AI) - эти поля undefined для других персонажей + let isOnSpecialCooldown = false; + if (attackerState.characterKey === 'balard') { + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns !== undefined && attackerState.silenceCooldownTurns > 0) isOnSpecialCooldown = true; + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns !== undefined && attackerState.manaDrainCooldownTurns > 0) isOnSpecialCooldown = true; + } + + // Нельзя кастовать бафф, если он уже активен (для баффов, которые не должны стакаться) + const isBuffAlreadyActive = ability.type === GAME_CONFIG.ACTION_TYPE_BUFF && attackerState.activeEffects.some(e => e.id === ability.id); + // Нельзя кастовать дебафф на цель, если он уже на ней (для определенных дебаффов) const isTargetedDebuff = ability.id === GAME_CONFIG.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_DEBUFF; - if (actionValid && isTargetedDebuff) { - if (defenderState.activeEffects.some(e => e.id === 'effect_' + ability.id)) { // Ищем эффект с префиксом effect_ - this.addToLog(`Эффект "${ability.name}" уже наложен на ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); - actionValid = false; - } - } + const effectIdForDebuff = 'effect_' + ability.id; + const isDebuffAlreadyOnTarget = isTargetedDebuff && defenderState.activeEffects.some(e => e.id === effectIdForDebuff); + + // Проверка всех условий + if (!hasEnoughResource) { this.addToLog(`${attackerState.name} пытается применить "${ability.name}", но не хватает ${attackerState.resourceName}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (actionValid && (isOnCooldown || isOnSpecialCooldown)) { this.addToLog(`"${ability.name}" еще на перезарядке.`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (actionValid && isSilenced) { this.addToLog(`${attackerState.name} не может использовать способности из-за безмолвия!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (actionValid && isBuffAlreadyActive) { this.addToLog(`Эффект "${ability.name}" уже активен!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + if (actionValid && isDebuffAlreadyOnTarget) { this.addToLog(`Эффект "${ability.name}" уже наложен на ${defenderState.name}!`, GAME_CONFIG.LOG_TYPE_INFO); actionValid = false; } + + + // Если все проверки пройдены, действие считается логически валидным if (actionValid) { - attackerState.currentResource -= ability.cost; - // Установка кулдауна - let baseCooldown = 0; - if (ability.cooldown) baseCooldown = ability.cooldown; - else if (attackerState.characterKey === 'balard') { // Специальные внутренние КД для AI - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE) { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN;} - else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && ability.internalCooldownValue) { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue; } - else { if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) baseCooldown = GAME_CONFIG[ability.internalCooldownFromConfig]; else if (typeof ability.internalCooldownValue === 'number') baseCooldown = ability.internalCooldownValue; } - } - if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; // +1, т.к. уменьшится в конце этого хода + attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); // Расходуем ресурс + // Добавляем насмешку ПРИ КАСТЕ СПОСОБНОСТИ (если есть такие для говорящего) + // getRandomTaunt теперь принимает gameDataForLogic и gameState + const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, gameData, this.gameState); + if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + + + // Применяем эффекты способности через gameLogic + // applyAbilityEffect теперь принимает gameDataForLogic и gameState serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + + // Установка кулдауна способности (после успешного каста) + let baseCooldown = 0; + if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { + baseCooldown = ability.cooldown; + } + // Специальные внутренние КД для Баларда - перебивают общий КД, если заданы + if (attackerState.characterKey === 'balard') { + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { + attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; + baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; // Специальный КД становится актуальным кулдауном + } else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { + attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; + baseCooldown = ability.internalCooldownValue; // Специальный КД становится актуальным кулдауном + } + } + + if (baseCooldown > 0 && attackerState.abilityCooldowns) { + // Устанавливаем кулдаун. Добавляем +1, т.к. кулдаун уменьшится в конце этого хода. + attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; + } } - } else { actionValid = false; } // Неизвестный тип действия + } else { + // Неизвестный тип действия + actingPlayerInfo.socket.emit('gameError', { message: `Неизвестный тип действия: ${actionData?.actionType}` }); + console.warn(`[Game ${this.id}] Получен неизвестный тип действия от ${actingPlayerInfo.identifier} (сокет ${requestingSocketId}): ${actionData?.actionType}`); + actionValid = false; // Помечаем как невалидное + } - if (!actionValid) { this.broadcastLogUpdate(); return; } // Если действие невалидно, просто отправляем лог и выходим + // --- Завершение хода --- + // Проверяем конец игры после выполнения действия + if (this.checkGameOver()) { + this.broadcastGameStateUpdate(); // Отправляем финальное состояние и лог всем + // Очистка игры теперь происходит ВНУТРИ checkGameOver через вызов _notifyGameEnded + return; // Если игра окончена, не переключаем ход + } - if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } // Проверяем конец игры после действия - setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); // Переключаем ход с задержкой + // Если действие было логически валидным и игра не окончена, переключаем ход с задержкой + if (actionValid) { + console.log(`[Game ${this.id}] Player action valid. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`); + setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); + } else { + // Если действие невалидно, просто обновляем лог (если что-то было добавлено), ход не переключается + console.log(`[Game ${this.id}] Player action invalid. Broadcasting log update.`); + this.broadcastLogUpdate(); + } } + /** + * Переключает ход на следующего бойца, обрабатывает эффекты конца хода и кулдауны. + */ switchTurn() { - if (!this.gameState || this.gameState.isGameOver) return; + // Проверяем, что игра активна и не окончена + if (!this.gameState || this.gameState.isGameOver) return; // Не переключаем ход, если игра окончена или состояние некорректно + // Определяем бойца, чей ход только что закончился const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const endingTurnActorState = this.gameState[endingTurnActorRole]; - const endingTurnCharacterData = this._getCharacterData(endingTurnActorState.characterKey); - if (!endingTurnCharacterData) { console.error(`SwitchTurn Error: No char data for ${endingTurnActorState.characterKey}`); return; } + const endingTurnCharacterData = this._getCharacterData(endingTurnActorState.characterKey); // Получаем данные персонажа - // Обработка эффектов в конце хода (DoT, HoT, истечение баффов/дебаффов) - serverGameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnCharacterData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); - serverGameLogic.updateBlockingStatus(this.gameState.player); // Обновляем статус блока для обоих - serverGameLogic.updateBlockingStatus(this.gameState.opponent); + // Проверяем наличие данных для обработки эффектов и КД + if (!endingTurnCharacterData) { + console.error(`[Game ${this.id}] SwitchTurn Error: No character data found for ending turn actor role ${endingTurnActorRole} with key ${endingTurnActorState.characterKey}. Cannot process end-of-turn effects.`); + // Критическая ошибка, игра должна быть очищена + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, 'switch_turn_data_error'); + } + return; + } else { + // Обработка активных эффектов в конце хода (DoT, HoT, истечение баффов/debuffs) для бойца, чей ход закончился + // processEffects теперь принимает gameDataForLogic и gameState + serverGameLogic.processEffects(endingTurnActorState.activeEffects, endingTurnActorState, endingTurnCharacterData.baseStats, endingTurnActorRole, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); - // Уменьшение кулдаунов способностей - if (endingTurnActorState.abilityCooldowns) { - serverGameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnCharacterData.abilities, endingTurnActorState.name, this.addToLog.bind(this)); - } - // Специальные КД для Баларда - if (endingTurnActorState.characterKey === 'balard') { - if (endingTurnActorState.silenceCooldownTurns !== undefined && endingTurnActorState.silenceCooldownTurns > 0) endingTurnActorState.silenceCooldownTurns--; - if (endingTurnActorState.manaDrainCooldownTurns !== undefined && endingTurnActorState.manaDrainCooldownTurns > 0) endingTurnActorState.manaDrainCooldownTurns--; - } - // Уменьшение длительности безмолвия на конкретные абилки (если это ход оппонента) - if (endingTurnActorRole === GAME_CONFIG.OPPONENT_ID) { // Если это был ход оппонента (AI или PvP) - const playerStateInGame = this.gameState.player; // Игрок, на которого могло быть наложено безмолвие - if (playerStateInGame.disabledAbilities?.length > 0) { - const playerCharAbilities = this._getCharacterAbilities(playerStateInGame.characterKey); - if (playerCharAbilities) serverGameLogic.processDisabledAbilities(playerStateInGame.disabledAbilities, playerCharAbilities, playerStateInGame.name, this.addToLog.bind(this)); + // Обновляем статус блока для обоих бойцов после обработки эффектов (т.к. эффекты блока могли закончиться) + // Важно обновить статус для ОБОИХ, т.к. эффекты на одном могут зависеть от статуса блока другого. + serverGameLogic.updateBlockingStatus(this.gameState.player); + serverGameLogic.updateBlockingStatus(this.gameState.opponent); + + // Уменьшение общих кулдаунов способностей для бойца, чей ход закончился + if (endingTurnActorState.abilityCooldowns) { + // processPlayerAbilityCooldowns теперь принимает gameDataForLogic (хотя он там не используется напрямую) + serverGameLogic.processPlayerAbilityCooldowns(endingTurnActorState.abilityCooldowns, endingTurnCharacterData.abilities, endingTurnActorState.name, this.addToLog.bind(this)); + } + // Специальные КД для Баларда (если он в слоте, который сейчас заканчивает ход) + if (endingTurnActorState.characterKey === 'balard') { + if (endingTurnActorState.silenceCooldownTurns !== undefined && endingTurnActorState.silenceCooldownTurns > 0) endingTurnActorState.silenceCooldownTurns--; + if (endingTurnActorState.manaDrainCooldownTurns !== undefined && endingTurnActorState.manaDrainCooldownTurns > 0) endingTurnActorState.manaDrainCooldownTurns--; + } + + // Уменьшение длительности заглушения конкретных способностей (если это ход того, кто был заглушен) + if (endingTurnActorState.disabledAbilities?.length > 0) { + const charAbilitiesForDisabledCheck = this._getCharacterAbilities(endingTurnActorState.characterKey); // Список абилок для поиска по ID + if (charAbilitiesForDisabledCheck) { + // processDisabledAbilities теперь принимает gameDataForLogic (хотя он там не используется напрямую) + serverGameLogic.processDisabledAbilities(endingTurnActorState.disabledAbilities, charAbilitiesForDisabledCheck, endingTurnActorState.name, this.addToLog.bind(this)); + } else { + console.warn(`[Game ${this.id}] SwitchTurn: Cannot process disabledAbilities for ${endingTurnActorState.name}: character abilities data not found.`); + } } } - if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } // Проверяем конец игры после эффектов + // Проверяем конец игры после обработки эффектов конца хода + if (this.checkGameOver()) { + this.broadcastGameStateUpdate(); // Отправляем финальное состояние и лог всем + // Очистка игры теперь происходит ВНУТРИ checkGameOver через вызов _notifyGameEnded + return; // Если игра окончена, останавливаемся + } - this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; // Меняем ход - if (this.gameState.isPlayerTurn) this.gameState.turnNumber++; // Новый ход игрока - увеличиваем номер хода + // Если игра не окончена, переключаем ход на следующего бойца с задержкой + this.gameState.isPlayerTurn = !this.gameState.isPlayerTurn; + // Если ход переключился на первого игрока (player), увеличиваем номер хода + if (this.gameState.isPlayerTurn) { + this.gameState.turnNumber++; + } + // Определяем бойца, чей ход начинается const currentTurnActorState = this.gameState.isPlayerTurn ? this.gameState.player : this.gameState.opponent; this.addToLog(`--- Начинается ход ${this.gameState.turnNumber} для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); + + // Отправляем обновленное состояние игры и лог всем клиентам в комнате this.broadcastGameStateUpdate(); - // Если ход AI, запускаем его логику - if (!this.gameState.isPlayerTurn) { - if (this.aiOpponent && this.opponentCharacterKey === 'balard') { - setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN); - } else { // PvP, ход второго игрока - this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.OPPONENT_ID }); - } - } else { // Ход первого игрока - this.io.to(this.id).emit('turnNotification', { currentTurn: GAME_CONFIG.PLAYER_ID }); + // Если ход AI, запускаем его логику с задержкой + // AI Балард всегда в слоте 'opponent' в AI режиме, и его characterKey === 'balard' + if (!this.gameState.isPlayerTurn && this.aiOpponent && this.opponentCharacterKey === 'balard') { + console.log(`[Game ${this.id}] Ход AI (Балард). Запускаем AI turn.`); + setTimeout(() => this.processAiTurn(), GAME_CONFIG.DELAY_OPPONENT_TURN || 1200); + } else { // Ход реального игрока (первого или второго в PvP) + // Игрок, чей ход, получит индикатор хода и активные кнопки через gameStateUpdate в клиентском ui.js + console.log(`[Game ${this.id}] Ход реального игрока ${currentTurnActorState.name} (роль: ${currentTurnActorState.id}).`); } } + /** + * Обрабатывает ход AI (Балард). + * Вызывается из switchTurn, если следующий ход принадлежит AI. + */ processAiTurn() { - if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.opponentCharacterKey !== 'balard') { + // Проверка: это точно ход AI Баларда и игра активна? + // AI Балард только в режиме 'ai', и его characterKey в gameState должен быть 'balard'. + if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent || this.gameState.opponent?.characterKey !== 'balard') { if(!this.gameState || this.gameState.isGameOver) return; // Если игра закончена, ничего не делаем - // Если не ход AI или это не AI Балард, выходим (хотя эта проверка должна быть раньше) + // Если не ход AI или это не AI Балард, выходим (на всякий случай) + // console.warn(`[Game ${this.id}] processAiTurn called when it's not AI Balard's turn or not AI mode.`); + // Если AI ход по какой-то причине пропущен, переключаем ход на игрока + console.warn(`[Game ${this.id}] Skipping AI turn logic (not AI Balard's turn or game not ready). Switching turn.`); + this.switchTurn(); // Пропускаем AI ход и переключаем обратно return; } - const aiDecision = serverGameLogic.decideAiAction(this.gameState, gameData, GAME_CONFIG, this.addToLog.bind(this)); - const attackerState = this.gameState.opponent; // AI всегда в слоте 'opponent' в AI режиме - const defenderState = this.gameState.player; - const attackerData = this._getCharacterData('balard'); - const defenderData = this._getCharacterData(defenderState.characterKey); // Обычно 'elena' + const attackerState = this.gameState.opponent; // AI Балард всегда в слоте 'opponent' в AI режиме + const defenderState = this.gameState.player; // Игрок всегда в слоте 'player' в AI режиме + // Получаем базовые данные персонажей для логики + const attackerData = this._getCharacterData('balard'); // Базовые данные Баларда + const defenderData = this._getCharacterData('elena'); // Базовые данные Елены (противник AI) - if (!attackerData || !defenderData) { this.addToLog("AI не может действовать: ошибка данных персонажа.", GAME_CONFIG.LOG_TYPE_SYSTEM); this.switchTurn(); return; } - let actionValid = true; + if (!attackerData || !defenderData) { + console.error(`[Game ${this.id}] CRITICAL ERROR: processAiTurn - Failed to load character data!`); + this.addToLog("AI не может действовать: ошибка данных персонажа.", GAME_CONFIG.LOG_TYPE_SYSTEM); + this.broadcastLogUpdate(); // Отправляем ошибку в лог + // Критическая ошибка, игра должна быть очищена + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, 'ai_data_load_failed'); + } + this.switchTurn(); // Пропускаем ход AI и переключаем обратно на игрока + return; + } - if (aiDecision.actionType === 'attack') { - // Лог атаки уже будет в performAttack + // Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены или Раскола Разума Альмагест) - повторная проверка из decideAiAction, на всякий случай + const isBalardFullySilenced = attackerState.activeEffects.some( + eff => eff.isFullSilence && eff.turnsLeft > 0 + ); + + if (isBalardFullySilenced) { + // AI под полным безмолвием просто атакует + // Лог о безмолвии и атаке в смятении добавляется в processAiTurn перед вызовом performAttack. + // decideAiAction просто возвращает действие. + // if (this.logBuffer.filter(log => log.message.includes('под действием Безмолвия')).length === 0) { // Логируем только если еще не логировали в decideAiAction + // this.addToLog(`😵 ${attackerState.name} под действием Безмолвия! Атакует в смятении.`, GAME_CONFIG.LOG_TYPE_EFFECT); + // } serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + + // Логика для "Силы Природы" и аналогов (неактуально для Баларда) + const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); + if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) { + const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerState.maxResource - attackerState.currentResource); + if (actualRegen > 0) { + attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); + this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); + } + } + + + // После атаки под безмолвием, переключаем ход + if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } // Проверяем Game Over + console.log(`[Game ${this.id}] AI (Балард) attacked while silenced. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`); + setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); + return; // Завершаем обработку AI хода + } + + + // AI принимает решение о действии, используя логику из gameLogic + // decideAiAction теперь принимает gameDataForLogic и gameState + const aiDecision = serverGameLogic.decideAiAction(this.gameState, gameData, GAME_CONFIG, this.addToLog.bind(this)); + + // --- Выполнение выбранного AI действия --- + if (aiDecision.actionType === 'attack') { + // AI Балард пока не имеет специфических насмешек при базовой атаке + + serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + + // Логика для "Силы Природы" и аналогов (неактуально для Баларда) + const delayedAttackBuffEffect = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK)); + if (delayedAttackBuffEffect && !delayedAttackBuffEffect.justCast) { + const actualRegen = Math.min(GAME_CONFIG.NATURE_STRENGTH_MANA_REGEN, attackerState.maxResource - attackerState.currentResource); + if (actualRegen > 0) { + attackerState.currentResource = Math.round(attackerState.currentResource + actualRegen); + this.addToLog(`🌿 ${attackerState.name} восстанавливает ${actualRegen} ${attackerState.resourceName} от эффекта "${delayedAttackBuffEffect.name}"!`, GAME_CONFIG.LOG_TYPE_HEAL); + } + } + } else if (aiDecision.actionType === 'ability' && aiDecision.ability) { const ability = aiDecision.ability; - // Проверки валидности (ресурс, КД) для AI - if (attackerState.currentResource < ability.cost || - (attackerState.abilityCooldowns && attackerState.abilityCooldowns[ability.id] > 0) || - (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && attackerState.silenceCooldownTurns > 0) || - (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && attackerState.manaDrainCooldownTurns > 0) - ) { - actionValid = false; - this.addToLog(`AI ${attackerState.name} не смог применить "${ability.name}" (недостаточно ресурса или на перезарядке). Решил атаковать.`, GAME_CONFIG.LOG_TYPE_INFO); - // Если выбранная способность невалидна, AI по умолчанию атакует - serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + // decideAiAction уже проверил ресурс, КД и специфические условия перед тем, как выбрать эту способность. + // Теперь просто выполняем ее. + + attackerState.currentResource = Math.round(attackerState.currentResource - ability.cost); // Расходуем ресурс + + // Добавляем насмешку ПРИ КАСТЕ СПОСОБНОСТИ Балардом (если есть такие для говорящего) + // В текущей реализации Балард не имеет секции selfCastAbility + // const taunt = serverGameLogic.getRandomTaunt(attackerState.characterKey, 'selfCastAbility', { abilityId: ability.id }, GAME_CONFIG, gameData, this.gameState); + // if (taunt !== "(Молчание)") this.addToLog(`${attackerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + + // applyAbilityEffect теперь принимает gameDataForLogic и gameState + serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + + // Установка кулдауна способности Баларда (после успешного каста) + let baseCooldown = 0; + if (typeof ability.cooldown === 'number' && ability.cooldown >= 0) { + baseCooldown = ability.cooldown; + } + // Специальные внутренние КД для Баларда - перебивают общий КД, если заданы + if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE && typeof GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN === 'number') { + attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; + baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; // Специальный КД становится актуальным кулдауном + } else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && typeof ability.internalCooldownValue === 'number') { + attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; + baseCooldown = ability.internalCooldownValue; // Специальный КД становится актуальным кулдауном } - if (actionValid) { // Если способность все еще валидна - attackerState.currentResource -= ability.cost; - // Установка кулдауна для AI - let baseCooldown = 0; - if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_SILENCE) { attackerState.silenceCooldownTurns = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN; baseCooldown = GAME_CONFIG.BALARD_SILENCE_INTERNAL_COOLDOWN;} - else if (ability.id === GAME_CONFIG.ABILITY_ID_BALARD_MANA_DRAIN && ability.internalCooldownValue) { attackerState.manaDrainCooldownTurns = ability.internalCooldownValue; baseCooldown = ability.internalCooldownValue;} - else { if (ability.internalCooldownFromConfig && GAME_CONFIG[ability.internalCooldownFromConfig]) baseCooldown = GAME_CONFIG[ability.internalCooldownFromConfig]; else if (typeof ability.internalCooldownValue === 'number') baseCooldown = ability.internalCooldownValue; } - if (baseCooldown > 0 && attackerState.abilityCooldowns) attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; - - serverGameLogic.applyAbilityEffect(ability, attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + if (baseCooldown > 0 && attackerState.abilityCooldowns) { + // Устанавливаем кулдаун. Добавляем +1, т.к. кулдаун уменьшится в конце этого хода. + attackerState.abilityCooldowns[ability.id] = baseCooldown + 1; } + } else if (aiDecision.actionType === 'pass') { // Если AI решил пропустить ход if (aiDecision.logMessage) this.addToLog(aiDecision.logMessage.message, aiDecision.logMessage.type); else this.addToLog(`${attackerState.name} обдумывает свой следующий ход...`, GAME_CONFIG.LOG_TYPE_INFO); - } else { // Неизвестное решение AI или ошибка - actionValid = false; - this.addToLog(`AI ${attackerState.name} не смог выбрать действие и атакует.`, GAME_CONFIG.LOG_TYPE_INFO); - serverGameLogic.performAttack(attackerState, defenderState, attackerData.baseStats, defenderData.baseStats, this.gameState, this.addToLog.bind(this), GAME_CONFIG, gameData); + } else { // Неизвестное решение AI или ошибка в логике decideAiAction + console.error(`[Game ${this.id}] AI (Балард) chose an invalid action type: ${aiDecision.actionType}. Defaulting to pass and logging error.`); + this.addToLog(`AI ${attackerState.name} не смог выбрать действие из-за ошибки. Пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); + // Критическая ошибка в логике AI, возможно, стоит завершить игру? + // Но пропуск хода - это более мягкое решение. } - // if (!actionValid && aiDecision.actionType !== 'pass') { - // this.addToLog(`${attackerState.name} не смог выполнить выбранное действие и пропускает ход.`, GAME_CONFIG.LOG_TYPE_INFO); - // } + // Проверяем конец игры после выполнения действия AI + if (this.checkGameOver()) { + this.broadcastGameStateUpdate(); // Отправляем финальное состояние и лог всем + // Очистка игры теперь происходит ВНУТРИ checkGameOver через вызов _notifyGameEnded + return; // Если игра окончена, останавливаемся + } - if (this.checkGameOver()) { this.broadcastGameStateUpdate(); return; } - this.switchTurn(); // Переключаем ход после действия AI + // Если игра не окончена, переключаем ход на игрока с задержкой + console.log(`[Game ${this.id}] AI action complete. Switching turn in ${GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500}ms.`); + setTimeout(() => { this.switchTurn(); }, GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION || 500); } + /** + * Проверяет, окончена ли игра (по HP бойцов). + * Если да, обновляет gameState, отправляет событие gameOver и уведомляет GameManager. + * Вызывается после каждого действия (игрока или AI) и после обработки эффектов конца хода. + * @returns {boolean} true, если игра окончена, иначе false. + */ checkGameOver() { - if (!this.gameState || this.gameState.isGameOver) return this.gameState ? this.gameState.isGameOver : true; // Если игра уже закончена, или нет gameState + // Проверка на конец игры происходит только если gameState существует и игра еще не помечена как оконченная + if (!this.gameState || this.gameState.isGameOver) return this.gameState ? this.gameState.isGameOver : true; - const playerState = this.gameState.player; - const opponentState = this.gameState.opponent; - - if (!playerState || !opponentState || opponentState.name === 'Ожидание игрока...') { - // Если одного из игроков нет (например, PvP игра ожидает второго), игра не может закончиться по HP + // Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами + // Проверка maxHp > 0 в gameState.opponent гарантирует, что оппонент не плейсхолдер + if (!this.gameState.player || !this.gameState.opponent || this.gameState.opponent.maxHp <= 0) { + // Если один из бойцов не готов (например, PvP игра ожидает второго игрока), игра не может закончиться по HP return false; } - const playerDead = playerState.currentHp <= 0; - const opponentDead = opponentState.currentHp <= 0; - if (playerDead || opponentDead) { - this.gameState.isGameOver = true; - const winnerRole = opponentDead ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; - const loserRole = opponentDead ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + // Используем внутреннюю функцию gameLogic для проверки условий победы/поражения по HP + // checkGameOverInternal теперь принимает gameDataForLogic и gameState + const isOver = serverGameLogic.checkGameOverInternal(this.gameState, GAME_CONFIG, gameData); + + // Если игра только что стала оконченной (был false, стал true) + if (isOver && !this.gameState.isGameOver) { + this.gameState.isGameOver = true; // Устанавливаем флаг + + const playerDead = this.gameState.player?.currentHp <= 0; + const opponentDead = this.gameState.opponent?.currentHp <= 0; + + // Определяем победителя и проигравшего по ролям + // Если оба мертвы одновременно, побеждает тот, кто не был убит последним действием, + // или определяется по правилам (здесь: player побеждает при одновременной смерти) + let winnerRole = null; + let loserRole = null; + // В AI режиме победителем всегда считается Player (если выжил), даже если AI тоже погиб. + // При дисконнекте в AI режиме победителя нет. + if (this.mode === 'ai') { + winnerRole = playerDead ? null : GAME_CONFIG.PLAYER_ID; // Player победил, если не умер. AI не "побеждает". + loserRole = playerDead ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + } else { // PvP режим + if (playerDead && opponentDead) { + // Оба мертвы, побеждает игрок (по правилам игры) + winnerRole = GAME_CONFIG.PLAYER_ID; + loserRole = GAME_CONFIG.OPPONENT_ID; + } else if (playerDead) { + // Игрок мертв, побеждает оппонент + winnerRole = GAME_CONFIG.OPPONENT_ID; + loserRole = GAME_CONFIG.PLAYER_ID; + } else if (opponentDead) { + // Оппонент мертв, побеждает игрок + winnerRole = GAME_CONFIG.PLAYER_ID; + loserRole = GAME_CONFIG.OPPONENT_ID; + } else { + // Этого не должно произойти, если isOver = true, но никто не мертв + console.error(`[Game ${this.id}] checkGameOverInternal returned true, but neither fighter is dead. GameState:`, this.gameState); + this.gameState.isGameOver = false; // Сбрасываем флаг, игра не окончена + return false; + } + } + const winnerState = this.gameState[winnerRole]; const loserState = this.gameState[loserRole]; const winnerName = winnerState?.name || (winnerRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник"); const loserName = loserState?.name || (loserRole === GAME_CONFIG.PLAYER_ID ? "Игрок" : "Противник"); + const loserCharacterKey = loserState?.characterKey || 'unknown'; // Ключ персонажа проигравшего - this.addToLog(`🏁 ПОБЕДА! ${winnerName} одолел(а) ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); - - // Дополнительные сообщения о конце игры - if (winnerState?.characterKey === 'elena') { - const tauntContext = loserState?.characterKey === 'balard' ? 'opponentNearDefeatBalard' : 'opponentNearDefeatAlmagest'; - const taunt = serverGameLogic.getElenaTaunt(tauntContext, {}, GAME_CONFIG, gameData, this.gameState); - if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerState.name}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); - - if (loserState?.characterKey === 'balard') this.addToLog(`Елена исполнила свой тяжкий долг. ${loserName} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM); - else if (loserState?.characterKey === 'almagest') this.addToLog(`Елена одержала победу над темной волшебницей ${loserName}!`, GAME_CONFIG.LOG_TYPE_SYSTEM); + // Добавляем сообщение о победе в лог + // В AI режиме при победе игрока лог специфичный. Если AI "победил" (игрок умер), лог тоже специфичный. + if (this.mode === 'ai') { + if (winnerRole === GAME_CONFIG.PLAYER_ID) { // Игрок победил + this.addToLog(`🏁 ПОБЕДА! Вы одолели ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } else { // Игрок проиграл AI + this.addToLog(`😭 ПОРАЖЕНИЕ! ${winnerName} оказался(лась) сильнее! 😭`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } + } else { // PvP режим + this.addToLog(`🏁 ПОБЕДА! ${winnerName} одолел(а) ${loserName}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); } + + // Добавляем дополнительные сообщения о конце игры (насмешка победителя) + // В AI режиме AI Балард не "говорит" в конце. Говорит только игрок, если победил. + const winningCharacterKey = winnerState?.characterKey; + if (this.mode === 'ai' && winningCharacterKey === 'elena') { + const taunt = serverGameLogic.getRandomTaunt(winningCharacterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState); + if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerName}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + } else if (this.mode === 'pvp' && (winningCharacterKey === 'elena' || winningCharacterKey === 'almagest')) { + const taunt = serverGameLogic.getRandomTaunt(winningCharacterKey, 'opponentNearDefeatCheck', {}, GAME_CONFIG, gameData, this.gameState); + if (taunt && taunt !== "(Молчание)") this.addToLog(`${winnerName}: "${taunt}"`, GAME_CONFIG.LOG_TYPE_INFO); + } + + // Специальные системные логи в конце игры, зависящие от проигравшего + if (loserCharacterKey === 'balard') { + this.addToLog(`Елена исполнила свой тяжкий долг. ${loserName} развоплощен...`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } else if (loserCharacterKey === 'almagest') { + this.addToLog(`Над полем битвы воцаряется тишина. ${loserName} побежден(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } else if (loserCharacterKey === 'elena') { + this.addToLog(`Свет погас. ${loserName} повержен(а).`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } + + console.log(`[Game ${this.id}] Game is over. Winner: ${winnerName} (${winnerRole}). Loser: ${loserName} (${loserRole}). Reason: HP <= 0.`); + + // Отправляем событие конца игры всем клиентам в комнате this.io.to(this.id).emit('gameOver', { - winnerId: winnerRole, - reason: `${loserName} побежден(а)`, - finalGameState: this.gameState, - log: this.consumeLogBuffer() + // В AI режиме winnerId отправляем только если победил игрок, иначе null + winnerId: this.mode === 'ai' ? (winnerRole === GAME_CONFIG.PLAYER_ID ? winnerRole : null) : winnerRole, + reason: `${loserName} побежден(а)`, // Причина для отображения на клиенте + finalGameState: this.gameState, // Финальное состояние игры для отображения + log: this.consumeLogBuffer(), // Отправляем весь накопленный лог (включая финальные сообщения) + loserCharacterKey: loserCharacterKey // Передаем characterKey проигравшего для клиентской анимации }); - return true; + + // УВЕДОМЛЯЕМ GAMEMANAGER ОБ ОКОНЧАНИИ ИГРЫ ДЛЯ ОЧИСТКИ + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, 'hp_zero'); + } else { + console.error(`[Game ${this.id}] GameManager reference missing or _cleanupGame not found! Game state will not be cleaned.`); + } + + + return true; // Игра окончена } - return false; + + // Если isOver было false, или gameState был некорректен, игра не окончена + return isOver; } + /** + * Завершает игру из-за отключения одного из игроков. + * Вызывается GameManager. + * @param {string} disconnectedSocketId - socketId отключившегося игрока. + * @param {string} disconnectedPlayerRole - Роль ('player' или 'opponent') отключившегося игрока. + * @param {string} disconnectedCharacterKey - Ключ персонажа отключившегося игрока. + */ + endGameDueToDisconnect(disconnectedSocketId, disconnectedPlayerRole, disconnectedCharacterKey) { + if (this.gameState && !this.gameState.isGameOver) { + this.gameState.isGameOver = true; // Помечаем игру как оконченную + + // Победитель - тот, кто остался. Его роль противоположна роли отключившегося. + const winnerRole = disconnectedPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const disconnectedCharacterData = this._getCharacterBaseData(disconnectedCharacterKey); // Данные уходящего персонажа + // Ключ персонажа победителя берем из gameState, т.к. там актуальное состояние слотов + const winnerCharacterKey = (winnerRole === GAME_CONFIG.PLAYER_ID) ? this.playerCharacterKey : this.opponentCharacterKey; + const winnerCharacterData = this._getCharacterBaseData(winnerCharacterKey); // Данные оставшегося персонажа + + // Добавляем сообщение о дисконнекте в лог + this.addToLog(`🔌 Игрок ${disconnectedCharacterData?.name || 'Неизвестный'} (${disconnectedPlayerRole}) отключился. Игра завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM); + // В AI режиме, если игрок отключился, AI не "побеждает" в стандартном смысле. Можно сделать специфичный лог. + if (this.mode === 'pvp') { + this.addToLog(`🏁 Победа присуждается ${winnerCharacterData?.name || winnerRole}! 🏁`, GAME_CONFIG.LOG_TYPE_SYSTEM); + } else { // AI режим + // AI игра завершается без формальной победы AI, если игрок ушел + // Лог выше уже достаточен + } + + + // Отправляем событие конца игры всем клиентам в комнате + // GameManager отвечает за удаление игры и очистку ссылок userIdentifierToGameId. + // Здесь мы только уведомляем клиентов. + this.io.to(this.id).emit('gameOver', { + // В AI режиме winnerId отправляем null, т.к. нет формального победителя + winnerId: this.mode === 'pvp' ? winnerRole : null, + reason: 'opponent_disconnected', // Причина для клиента + finalGameState: this.gameState, // Финальное состояние игры для отображения + log: this.consumeLogBuffer(), // Отправляем весь накопленный лог + // Передаем characterKey проигравшего (того, кто отключился) для клиентской анимации (растворения) + loserCharacterKey: disconnectedCharacterKey + }); + + console.log(`[Game ${this.id}] Game ended due to disconnect. Winner (PvP only): ${winnerCharacterData?.name || winnerRole}. Disconnected: ${disconnectedCharacterData?.name || disconnectedPlayerRole}.`); + + // УВЕДОМЛЯЕМ GAMEMANAGER ОБ ОКОНЧАНИИ ИГРЫ ДЛЯ ОЧИСТКИ + if (this.gameManager && typeof this.gameManager._cleanupGame === 'function') { + this.gameManager._cleanupGame(this.id, 'opponent_disconnected'); + } else { + console.error(`[Game ${this.id}] GameManager reference missing or _cleanupGame not found! Game state will not be cleaned.`); + } + + } + } + + + /** + * Добавляет сообщение в буфер лога игры. + * @param {string} message - Текст сообщения. + * @param {string} [type=GAME_CONFIG.LOG_TYPE_INFO] - Тип сообщения (для стилей на клиенте). + */ addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { if (!message) return; this.logBuffer.push({ message, type, timestamp: Date.now() }); + // В активной игре можно сразу отправлять лог, не дожидаясь gameStateUpdate + // Это может улучшить отзывчивость лога. + // if (this.gameState && !this.gameState.isGameOver) { + // this.broadcastLogUpdate(); + // } } + /** + * Очищает буфер лога и возвращает его содержимое. + * @returns {Array} Массив сообщений лога. + */ consumeLogBuffer() { const logs = [...this.logBuffer]; this.logBuffer = []; return logs; } + /** + * Отправляет текущее состояние игры и содержимое лога всем клиентам в комнате игры. + */ broadcastGameStateUpdate() { if (!this.gameState) return; + // Отправляем полное состояние игры и буфер лога всем клиентам в комнате this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); } - broadcastLogUpdate() { // Если нужно отправить только лог без полного gameState + /** + * Отправляет только содержимое лога всем клиентам в комнате игры (если нужно обновить только лог). + * Полезно для сообщений, которые не меняют gameState, но должны отобразиться. + */ + broadcastLogUpdate() { if (this.logBuffer.length > 0) { this.io.to(this.id).emit('logUpdate', { log: this.consumeLogBuffer() }); } } - // Вспомогательные функции для получения данных персонажа + // --- Вспомогательные функции для получения данных персонажа из data.js --- + // Скопировано из gameManager.js, т.к. GameInstance тоже использует gameData напрямую + /** + * Получает базовые статы и список способностей для персонажа по ключу. + * Эти функции предназначены для использования ВНУТРИ GameManager или GameInstance. + * @param {string} key - Ключ персонажа ('elena', 'balard', 'almagest'). + * @returns {{baseStats: object, abilities: array}|null} Объект с базовыми статами и способностями, или null. + */ _getCharacterData(key) { - if (!key) return null; + if (!key) { console.warn("GameInstance::_getCharacterData called with null/undefined key."); return null; } switch (key) { case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities }; - case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; - case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; - default: console.error(`_getCharacterData: Unknown character key "${key}"`); return null; + case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; // Балард использует opponentAbilities из data.js + case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; // Альмагест использует almagestAbilities из data.js + default: console.error(`GameInstance::_getCharacterData: Unknown character key "${key}"`); return null; } } + /** + * Получает только базовые статы для персонажа по ключу. + * @param {string} key - Ключ персонажа. + * @returns {object|null} Базовые статы или null. + */ _getCharacterBaseData(key) { - if (!key) return null; const charData = this._getCharacterData(key); return charData ? charData.baseStats : null; } + /** + * Получает только список способностей для персонажа по ключу. + * @param {string} key - Ключ персонажа. + * @returns {array|null} Список способностей или null. + */ _getCharacterAbilities(key) { - if (!key) return null; const charData = this._getCharacterData(key); return charData ? charData.abilities : null; } diff --git a/server_modules/gameLogic.js b/server_modules/gameLogic.js index 58a75d5..3bfbfb1 100644 --- a/server_modules/gameLogic.js +++ b/server_modules/gameLogic.js @@ -2,10 +2,36 @@ const GAME_CONFIG = require('./config'); const gameData = require('./data'); // Загружаем один раз на уровне модуля -// --- Вспомогательные Функции --- +// --- Вспомогательные Функции для gameLogic --- + +// Вспомогательная функция для получения данных персонажа (baseStats и abilities) +// Нужна здесь, так как объект gameData сам по себе не имеет этих методов. +// Принимает gameDataForLogic как аргумент для гибкости, по умолчанию использует глобальный gameData. +function _getCharacterDataForLogic(key, gameDataForLogic = gameData) { + if (!key) return null; + switch (key) { + case 'elena': return { baseStats: gameDataForLogic.playerBaseStats, abilities: gameDataForLogic.playerAbilities }; + case 'balard': return { baseStats: gameDataForLogic.opponentBaseStats, abilities: gameDataForLogic.opponentAbilities }; // Балард использует opponentAbilities + case 'almagest': return { baseStats: gameDataForLogic.almagestBaseStats, abilities: gameDataForLogic.almagestAbilities }; // Альмагест использует almagestAbilities + default: console.error(`_getCharacterDataForLogic: Неизвестный ключ персонажа "${key}"`); return null; + } +} +// Вспомогательная функция для получения только базовых статов персонажа +function _getCharacterBaseDataForLogic(key, gameDataForLogic = gameData) { + const charData = _getCharacterDataForLogic(key, gameDataForLogic); + return charData ? charData.baseStats : null; +} +// Вспомогательная функция для получения только способностей персонажа +function _getCharacterAbilitiesForLogic(key, gameDataForLogic = gameData) { + const charData = _getCharacterDataForLogic(key, gameDataForLogic); + return charData ? charData.abilities : null; +} + /** * Обрабатывает активные эффекты (баффы/дебаффы) для бойца в конце его хода. + * Длительность эффекта уменьшается на 1. + * Периодические эффекты (DoT, ресурсный дебафф и т.п.) срабатывают, если эффект не "justCast" в этом ходу. * @param {Array} effectsArray - Массив активных эффектов бойца. * @param {Object} ownerState - Состояние бойца (currentHp, currentResource и т.д.). * @param {Object} ownerBaseStats - Базовые статы бойца (включая characterKey). @@ -22,51 +48,41 @@ function processEffects(effectsArray, ownerState, ownerBaseStats, ownerId, curre let effectsToRemoveIndexes = []; + // Важно: Сначала обрабатываем эффекты, затем уменьшаем длительность, затем удаляем. for (let i = 0; i < effectsArray.length; i++) { const eff = effectsArray[i]; - const isNatureStrengthEffect = eff.id === GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH || eff.id === GAME_CONFIG.ABILITY_ID_ALMAGEST_BUFF_ATTACK; - // if (isNatureStrengthEffect) { // Отладочный лог - // console.log(`[NATURE_STRENGTH_DEBUG] processEffects for ${ownerState.name}: Effect: ${eff.name}, justCast (before): ${eff.justCast}, turnsLeft (before): ${eff.turnsLeft}`); - // } - - // --- Обработка эффектов с действием каждый ход (ДО уменьшения turnsLeft, если justCast === false) --- - if (!eff.justCast) { // Эффекты, которые тикают, не должны тикать в ход наложения - if (eff.isFullSilence && eff.power && typeof eff.power === 'number' && eff.power > 0) { + // --- Применяем эффект (DoT, сжигание ресурса и т.п.), если он не только что наложен в этом ходу --- + if (!eff.justCast) { + // Обработка урона от эффектов полного безмолвия (Гипнотический Взгляд, Раскол Разума) + // Эти эффекты наносят урон цели В КОНЦЕ ее хода + if (eff.isFullSilence && typeof eff.power === 'number' && eff.power > 0) { const damage = eff.power; - ownerState.currentHp = Math.max(0, ownerState.currentHp - damage); + // ИСПРАВЛЕНО: Округляем результат вычитания HP + ownerState.currentHp = Math.max(0, Math.round(ownerState.currentHp - damage)); if (addToLogCallback) addToLogCallback(`😵 Эффект "${eff.name}" наносит ${damage} урона ${ownerName}!`, configToUse.LOG_TYPE_DAMAGE); } + // Обработка сжигания ресурса (Печать Слабости, Проклятие Увядания) + // Эти эффекты сжигают ресурс цели В КОНЦЕ ее хода if ((eff.id === 'effect_' + configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || eff.id === 'effect_' + configToUse.ABILITY_ID_ALMAGEST_DEBUFF) && eff.power > 0) { const resourceToBurn = eff.power; if (ownerState.currentResource > 0) { const actualBurn = Math.min(ownerState.currentResource, resourceToBurn); - ownerState.currentResource = Math.max(0, ownerState.currentResource - actualBurn); + // ИСПРАВЛЕНО: Округляем результат вычитания ресурса + ownerState.currentResource = Math.max(0, Math.round(ownerState.currentResource - actualBurn)); if (addToLogCallback) addToLogCallback(`🔥 Эффект "${eff.name}" сжигает ${actualBurn} ${ownerBaseStats.resourceName} у ${ownerName}!`, configToUse.LOG_TYPE_EFFECT); } } + // Примечание: Отложенные эффекты (например, Сила Природы) применяют свою силу в gameInstance.processPlayerAction (после атаки), а не здесь. } - // --- Уменьшение длительности эффекта --- - if (eff.justCast) { - eff.justCast = false; - } else { - // Не уменьшаем turnsLeft для эффектов, которые должны длиться до следующей атаки - // и не имеют фиксированного числа ходов (таких как Сила Природы, если бы она так работала). - // В нашем случае Сила Природы имеет duration, поэтому turnsLeft уменьшается. - eff.turnsLeft--; - } + // --- Уменьшаем длительность --- + eff.turnsLeft--; + eff.justCast = false; // Эффект больше не считается "just cast" после обработки этого хода - // if (isNatureStrengthEffect) { // Отладочный лог - // console.log(`[NATURE_STRENGTH_DEBUG] processEffects for ${ownerState.name}: Effect: ${eff.name}, justCast (after): ${eff.justCast}, turnsLeft (after): ${eff.turnsLeft}`); - // } - - // --- Удаление закончившихся эффектов --- + // --- Отмечаем для удаления, если длительность закончилась --- if (eff.turnsLeft <= 0) { - // if (isNatureStrengthEffect) { // Отладочный лог - // console.log(`[NATURE_STRENGTH_DEBUG] processEffects for ${ownerState.name}: Effect ${eff.name} REMOVED because turnsLeft is 0.`); - // } effectsToRemoveIndexes.push(i); if (addToLogCallback) { addToLogCallback(`Эффект "${eff.name}" на ${ownerName} закончился.`, configToUse.LOG_TYPE_EFFECT); @@ -74,37 +90,57 @@ function processEffects(effectsArray, ownerState, ownerBaseStats, ownerId, curre } } + // Удаляем эффекты с конца, чтобы не нарушить индексы for (let i = effectsToRemoveIndexes.length - 1; i >= 0; i--) { effectsArray.splice(effectsToRemoveIndexes[i], 1); } } -/** Обрабатывает отсчет для отключенных (заглушенных) способностей игрока. */ +/** + * Обрабатывает отсчет для отключенных (заглушенных) способностей игрока в конце его хода. + * Длительность заглушения уменьшается на 1. + * @param {Array} disabledAbilitiesArray - Массив объектов заглушенных способностей. + * @param {Array} characterAbilities - Полный список способностей персонажа (для получения имени). + * @param {string} characterName - Имя персонажа (для лога). + * @param {function} addToLogCallback - Функция для добавления лога. + * @returns {void} - Модифицирует disabledAbilitiesArray напрямую. + */ function processDisabledAbilities(disabledAbilitiesArray, characterAbilities, characterName, addToLogCallback) { if (!disabledAbilitiesArray || disabledAbilitiesArray.length === 0) return; const stillDisabled = []; disabledAbilitiesArray.forEach(dis => { - dis.turnsLeft--; + dis.turnsLeft--; // Уменьшаем длительность заглушения if (dis.turnsLeft > 0) { stillDisabled.push(dis); } else { if (addToLogCallback) { const ability = characterAbilities.find(ab => ab.id === dis.abilityId); + // Проверка на заглушающий эффект тоже должна быть удалена из activeEffects в processEffects + // Здесь мы только обрабатываем список disabledAbilities, удаляя запись if (ability) addToLogCallback(`Способность ${characterName} "${ability.name}" больше не заглушена!`, GAME_CONFIG.LOG_TYPE_INFO); } } }); + // Очищаем исходный массив и добавляем только те, что еще активны disabledAbilitiesArray.length = 0; disabledAbilitiesArray.push(...stillDisabled); } -/** Обрабатывает отсчет кулдаунов для способностей. */ +/** + * Обрабатывает отсчет общих кулдаунов для способностей в конце хода. + * Длительность кулдауна уменьшается на 1. + * @param {object} cooldownsObject - Объект с кулдаунами способностей ({ abilityId: turnsLeft }). + * @param {Array} abilitiesList - Полный список способностей персонажа (для получения имени). + * @param {string} ownerName - Имя персонажа (для лога). + * @param {function} addToLogCallback - Функция для добавления лога. + * @returns {void} - Модифицирует cooldownsObject напрямую. + */ function processPlayerAbilityCooldowns(cooldownsObject, abilitiesList, ownerName, addToLogCallback) { if (!cooldownsObject || !abilitiesList) return; for (const abilityId in cooldownsObject) { if (cooldownsObject.hasOwnProperty(abilityId) && cooldownsObject[abilityId] > 0) { - cooldownsObject[abilityId]--; + cooldownsObject[abilityId]--; // Уменьшаем кулдаун if (cooldownsObject[abilityId] === 0) { const ability = abilitiesList.find(ab => ab.id === abilityId); if (ability && addToLogCallback) { @@ -115,215 +151,334 @@ function processPlayerAbilityCooldowns(cooldownsObject, abilitiesList, ownerName } } -/** Обновляет статус 'isBlocking' на основе активных эффектов. */ +/** + * Обновляет статус 'isBlocking' на основе активных эффектов. + * @param {object} fighterState - Состояние бойца. + * @returns {void} - Модифицирует fighterState.isBlocking. + */ function updateBlockingStatus(fighterState) { if (!fighterState) return; + // Боец считается блокирующим, если у него есть активный эффект, дающий блок (grantsBlock: true) с turnsLeft > 0 fighterState.isBlocking = fighterState.activeEffects.some(eff => eff.grantsBlock && eff.turnsLeft > 0); } /** - * Выбирает подходящую насмешку для Елены. + * Получает случайную насмешку из системы насмешек для определенного персонажа. + * Ищет фразу в gameData.tauntSystem[speakerCharacterKey][opponentCharacterKey][trigger][context]. + * @param {string} speakerCharacterKey - Ключ персонажа, который произносит насмешку ('elena' или 'almagest' или 'balard'). + * @param {string} trigger - Тип события, вызвавшего насмешку (например, 'selfCastAbility', 'onOpponentAction', 'battleStart', 'basicAttack', 'opponentNearDefeatCheck'). + * @param {object} context - Дополнительный контекст (например, { abilityId: 'fireball' }, { outcome: 'success' }). + * @param {object} configToUse - Конфигурационный объект игры. + * @param {object} gameDataForLogic - Полный объект gameData. + * @param {object} currentGameState - Текущее состояние игры. + * @returns {string} Текст насмешки или "(Молчание)". */ -function getElenaTaunt(trigger, context = {}, configToUse, gameDataForLogic = gameData, currentGameState) { - if (!currentGameState || !currentGameState.player || currentGameState.player.characterKey !== 'elena') { +function getRandomTaunt(speakerCharacterKey, trigger, context = {}, configToUse, gameDataForLogic = gameData, currentGameState) { + // Проверяем наличие системы насмешек для говорящего персонажа + const speakerTauntSystem = gameDataForLogic?.tauntSystem?.[speakerCharacterKey]; + if (!speakerTauntSystem) return "(Молчание)"; // Нет насмешек для этого персонажа + + // Определяем противника, чтобы выбрать соответствующую ветку насмешек + // Для этого нужно найти в gameState, кто из player/opponent имеет characterKey говорящего, + // и взять characterKey другого. + const speakerRole = currentGameState?.player?.characterKey === speakerCharacterKey ? + GAME_CONFIG.PLAYER_ID : + (currentGameState?.opponent?.characterKey === speakerCharacterKey ? + GAME_CONFIG.OPPONENT_ID : null); + + if (speakerRole === null) { + console.warn(`getRandomTaunt: Speaker character key "${speakerCharacterKey}" not found in current game state roles.`); return "(Молчание)"; } - const opponentKey = currentGameState.opponent.characterKey; - const tauntSystem = gameDataForLogic?.elenaTauntSystem; - const tauntBranch = opponentKey === 'balard' ? tauntSystem?.aiBalard : tauntSystem?.pvpAlmagest; - if (!tauntBranch) return "(Молчание)"; + + const opponentRole = speakerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const opponentCharacterKey = currentGameState?.[opponentRole]?.characterKey; + + const tauntBranch = speakerTauntSystem[opponentCharacterKey]; + if (!tauntBranch) { + // console.warn(`getRandomTaunt: No taunt branch found for speaker "${speakerCharacterKey}" against opponent "${opponentCharacterKey}".`); + return "(Молчание)"; // Нет насмешек против этого оппонента + } + let potentialTaunts = []; - const opponentHpPerc = (currentGameState.opponent.currentHp / currentGameState.opponent.maxHp) * 100; - const isOpponentLowHpForDomination = opponentKey === 'balard' && opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT; - const isOpponentNearDefeat = opponentHpPerc < 20; - if (trigger === 'opponentNearDefeatCheck' && isOpponentNearDefeat && tauntBranch.onBattleState?.opponentNearDefeat) { - potentialTaunts = tauntBranch.onBattleState.opponentNearDefeat; + // Навигация по структуре tauntSystem в зависимости от триггера и контекста + if (trigger === 'battleStart') { + potentialTaunts = tauntBranch.onBattleState?.start; } - else if (trigger === 'opponentAction' && context.abilityId) { + else if (trigger === 'opponentNearDefeatCheck') { // Проверка на низкое HP противника для специальных фраз + const opponentState = currentGameState?.[opponentRole]; // Состояние противника для проверки HP + // Проверяем, что состояние оппонента существует и его HP ниже порога (например, 20%) + if (opponentState && opponentState.maxHp > 0 && opponentState.currentHp / opponentState.maxHp < 0.20) { + potentialTaunts = tauntBranch.onBattleState?.opponentNearDefeat; + } + } + else if (trigger === 'selfCastAbility' && context.abilityId) { + potentialTaunts = tauntBranch.selfCastAbility?.[context.abilityId]; + } + else if (trigger === 'basicAttack' && tauntBranch.basicAttack) { + const opponentState = currentGameState?.[opponentRole]; // Состояние противника + // Специальная логика для базовой атаки Елены против Баларда (милосердие/доминирование) + if (speakerCharacterKey === 'elena' && opponentCharacterKey === 'balard' && opponentState) { + const opponentHpPerc = (opponentState.currentHp / opponentState.maxHp) * 100; + if (opponentHpPerc <= configToUse.PLAYER_MERCY_TAUNT_THRESHOLD_PERCENT) { + potentialTaunts = tauntBranch.basicAttack.dominating; + } else { + potentialTaunts = tauntBranch.basicAttack.merciful; + } + } else { // Общая логика для PvP или Елена/Балард вне порога + potentialTaunts = tauntBranch.basicAttack.general; + } + } + // Реакция на действие противника + else if (trigger === 'onOpponentAction' && context.abilityId) { const actionResponses = tauntBranch.onOpponentAction?.[context.abilityId]; if (actionResponses) { + // Если структура содержит вложенные результаты (например, успех/провал Безмолвия) if (typeof actionResponses === 'object' && !Array.isArray(actionResponses) && context.outcome && context.outcome in actionResponses) { - potentialTaunts = actionResponses[context.outcome]; + potentialTaunts = actionResponses[context.outcome]; // Например, onOpponentAction.silence.success } else if (Array.isArray(actionResponses)) { - potentialTaunts = actionResponses; + potentialTaunts = actionResponses; // Прямой массив фраз для способности } } } - else if (trigger === 'opponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) { + // Реакция на попадание/блок атаки противника + // Примечание: Эти триггеры срабатывают, когда по ГОВОРЯЩЕМУ попала атака или он ее заблокировал. + // Вызываются из performAttack, где известно, кто атакует и кто защищается. + else if (trigger === 'onOpponentAttackBlocked' && tauntBranch.onOpponentAction?.attackBlocked) { potentialTaunts = tauntBranch.onOpponentAction.attackBlocked; } - else if (trigger === 'opponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) { + else if (trigger === 'onOpponentAttackHit' && tauntBranch.onOpponentAction?.attackHits) { potentialTaunts = tauntBranch.onOpponentAction.attackHits; } - else if (trigger === 'playerActionCast' && context.abilityId && tauntBranch.onPlayerCast?.[context.abilityId]) { - potentialTaunts = tauntBranch.onPlayerCast[context.abilityId]; - } - else if (trigger === 'playerBasicAttack') { - if (isOpponentLowHpForDomination) { - const pools = tauntBranch.base?.dominating || {}; - potentialTaunts = [ ...(pools.creatorVsCreation || []), ...(pools.betrayalOfLight || []), ...(pools.ingratitudeContempt || []), ...(pools.unmakingThreats || []) ]; - } else if (opponentKey === 'balard' && !isOpponentLowHpForDomination) { - potentialTaunts = tauntBranch.base?.mercifulAttack || []; - } else { - potentialTaunts = tauntBranch.base?.generalAttack || tauntBranch.base?.mercifulAttack || []; - } - } - else if (trigger === 'playerActionGeneral') { - if (isOpponentLowHpForDomination) { - const pools = tauntBranch.base?.dominating || {}; - potentialTaunts = [ ...(pools.creatorVsCreation || []), ...(pools.betrayalOfLight || []), ...(pools.ingratitudeContempt || []), ...(pools.unmakingThreats || []) ]; - } else if (opponentKey === 'balard' && !isOpponentLowHpForDomination) { - potentialTaunts = tauntBranch.base?.mercifulCast || []; - } else { - potentialTaunts = tauntBranch.base?.generalCast || tauntBranch.base?.mercifulCast || []; - } - } - else if (trigger === 'battleStart') { - const startTaunts = (opponentKey === 'balard' ? tauntBranch.onBattleState?.startMerciful : tauntBranch.onBattleState?.start); - if (startTaunts) potentialTaunts = startTaunts; - } + + // Если по прямому триггеру не найдено, возвращаем "(Молчание)". + // Можно добавить фоллбэк на общие фразы, если требуется более разговорчивый персонаж. + // Например: if ((!potentialTaunts || potentialTaunts.length === 0) && tauntBranch.basicAttack?.general) { potentialTaunts = tauntBranch.basicAttack.general; } if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) { - if (opponentKey === 'balard') { - if (isOpponentLowHpForDomination) { - const pools = tauntBranch.base?.dominating || {}; - potentialTaunts = [ ...(pools.creatorVsCreation || []), ...(pools.betrayalOfLight || []), ...(pools.ingratitudeContempt || []), ...(pools.unmakingThreats || []) ]; - } else { - potentialTaunts = [...(tauntBranch.base?.mercifulAttack || []), ...(tauntBranch.base?.mercifulCast || [])]; - } - } else { - potentialTaunts = [...(tauntBranch.base?.generalAttack || []), ...(tauntBranch.base?.generalCast || [])]; - } + return "(Молчание)"; // Возвращаем молчание, если ничего не найдено } - if (!Array.isArray(potentialTaunts) || potentialTaunts.length === 0) return "(Молчание)"; - return potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)] || "(Молчание)"; + + // Возвращаем случайную фразу из найденного массива + const selectedTaunt = potentialTaunts[Math.floor(Math.random() * potentialTaunts.length)]; + return selectedTaunt || "(Молчание)"; // Фоллбэк на "(Молчание)" если массив был пуст после всех проверок } // --- Основные Игровые Функции --- +/** + * Обрабатывает базовую атаку одного бойца по другому. + * @param {object} attackerState - Состояние атакующего бойца. + * @param {object} defenderState - Состояние защищающегося бойца. + * @param {object} attackerBaseStats - Базовые статы атакующего. + * @param {object} defenderBaseStats - Базовые статы защищающегося. + * @param {object} currentGameState - Текущее состояние игры (для насмешек). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурация игры. + * @param {object} gameDataForLogic - Данные игры (для насмешек). + */ function performAttack(attackerState, defenderState, attackerBaseStats, defenderBaseStats, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) { + // Расчет базового урона с вариацией let damage = Math.floor(attackerBaseStats.attackPower * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)); - let tauntMessagePart = ""; + let tauntMessagePart = ""; // Переменная для насмешки защищающегося + // Проверка на блок if (defenderState.isBlocking) { const initialDamage = damage; damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); - if (defenderState.characterKey === 'elena') { - const blockTaunt = getElenaTaunt('opponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState); + // Проверка на насмешку ОТ защищающегося (Елены или Альмагест) при блокировании атаки + if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') { + // getRandomTaunt принимает speaker (защищающийся), trigger, context, config, gameData, gameState + const blockTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState); if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`; } - if (addToLogCallback) addToLogCallback(`🛡️ ${defenderBaseStats.name} блокирует атаку! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK); + + if (addToLogCallback) addToLogCallback(`🛡️ ${defenderBaseStats.name} блокирует атаку ${attackerBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK); } else { let hitMessage = `${attackerBaseStats.name} атакует ${defenderBaseStats.name}! Наносит ${damage} урона.`; - if (defenderState.characterKey === 'elena') { - const hitTaunt = getElenaTaunt('opponentAttackHit', {}, configToUse, gameDataForLogic, currentGameState); + // Проверка на насмешку ОТ защищающегося (Елены или Альмагест) при попадании атаки + if (defenderState.characterKey === 'elena' || defenderState.characterKey === 'almagest') { + // getRandomTaunt принимает speaker (защищающийся), trigger, context, config, gameData, gameState + const hitTaunt = getRandomTaunt(defenderState.characterKey, 'onOpponentAttackHit', {}, configToUse, gameDataForLogic, currentGameState); if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`; } if (addToLogCallback) addToLogCallback(hitMessage, configToUse.LOG_TYPE_DAMAGE); } - defenderState.currentHp = Math.max(0, defenderState.currentHp - damage); + // Применяем урон, убеждаемся, что HP не ниже нуля + // ИСПРАВЛЕНО: Округляем результат вычитания HP + defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage)); } +/** + * Применяет эффект способности. + * @param {object} ability - Объект способности. + * @param {object} casterState - Состояние бойца, применившего способность. + * @param {object} targetState - Состояние цели способности. + * @param {object} casterBaseStats - Базовые статы кастера. + * @param {object} targetBaseStats - Базовые статы цели. + * @param {object} currentGameState - Текущее состояние игры (для насмешек). + * @param {function} addToLogCallback - Функция для добавления лога. + * @param {object} configToUse - Конфигурация игры. + * @param {object} gameDataForLogic - Данные игры (для насмешек). + */ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats, targetBaseStats, currentGameState, addToLogCallback, configToUse, gameDataForLogic = gameData) { - let tauntMessagePart = ""; + let tauntMessagePart = ""; // Переменная для насмешки, если она связана с результатом эффекта или реакцией цели + + // Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под способность противника + if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') { + // Триггер 'onOpponentAction' с abilityId противника + const reactionTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id }, configToUse, gameDataForLogic, currentGameState); + if (reactionTaunt !== "(Молчание)") tauntMessagePart = ` (${reactionTaunt})`; + } else { + tauntMessagePart = ""; // Другие персонажи (Балард) не имеют реакционных насмешек такого типа + } + + switch (ability.type) { case configToUse.ACTION_TYPE_HEAL: const healAmount = Math.floor(ability.power * (configToUse.HEAL_VARIATION_MIN + Math.random() * configToUse.HEAL_VARIATION_RANGE)); const actualHeal = Math.min(healAmount, casterBaseStats.maxHp - casterState.currentHp); if (actualHeal > 0) { - casterState.currentHp += actualHeal; - if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL); + // ИСПРАВЛЕНО: Округляем результат прибавления HP + casterState.currentHp = Math.round(casterState.currentHp + actualHeal); + // --- ИЗМЕНЕНИЕ: Добавляем название способности в лог лечения --- + if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!${tauntMessagePart}`, configToUse.LOG_TYPE_HEAL); + // --- КОНЕЦ ИЗМЕНЕНИЯ --- } else { - if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} уже имеет полное здоровье или эффект не дал лечения.`, configToUse.LOG_TYPE_INFO); + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); } break; case configToUse.ACTION_TYPE_DAMAGE: let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)); + // Проверка на блок цели if (targetState.isBlocking) { const initialDamage = damage; damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); - if (targetState.characterKey === 'elena') { - const blockTaunt = getElenaTaunt('opponentAttackBlocked', {abilityId: ability.id} , configToUse, gameDataForLogic, currentGameState); - if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`; - } - if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}"! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK); + // Проверка на насмешку ОТ цели (Елены или Альмагест), если она заблокировала урон от способности - перенесено наверх + // if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') { + // const blockTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAttackBlocked', {abilityId: ability.id} , configToUse, gameDataForLogic, currentGameState); + // if (blockTaunt !== "(Молчание)") tauntMessagePart = ` (${blockTaunt})`; + // } + if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).${tauntMessagePart}`, configToUse.LOG_TYPE_BLOCK); } - targetState.currentHp = Math.max(0, targetState.currentHp - damage); + // Применяем урон, убеждаемся, что HP не ниже нуля + // ИСПРАВЛЕНО: Округляем результат вычитания HP + targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage)); if (addToLogCallback && !targetState.isBlocking) { - let hitMessage = `💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`; - if (targetState.characterKey === 'elena') { - const hitTaunt = getElenaTaunt('opponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState); - if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`; - } + let hitMessage = `💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!${tauntMessagePart}`; + // Проверка на насмешку ОТ цели (Елены или Альмагест), если по ней попала способность - перенесено наверх + // if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') { + // const hitTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState); + // if (hitTaunt !== "(Молчание)") hitMessage += ` (${hitTaunt})`; + // } addToLogCallback(hitMessage, configToUse.LOG_TYPE_DAMAGE); } break; case configToUse.ACTION_TYPE_BUFF: + // Если бафф уже активен, не применяем его повторно (эта проверка уже есть в gameInstance) + // Проверка на .some здесь опциональна, т.к. вызывающий код должен гарантировать уникальность if (!casterState.activeEffects.some(e => e.id === ability.id)) { let effectDescription = ability.description; if (typeof ability.descriptionFunction === 'function') { + // Для описания баффа может потребоваться информация о противнике const opponentRole = casterState.id === configToUse.PLAYER_ID ? configToUse.OPPONENT_ID : configToUse.PLAYER_ID; const opponentCurrentState = currentGameState[opponentRole]; - const opponentDataForDesc = opponentCurrentState ? gameDataForLogic[opponentCurrentState.characterKey + 'BaseStats'] : gameDataForLogic.playerBaseStats; // Фоллбэк + // Получаем базовые статы противника, если он определен, для функции описания + const opponentDataForDesc = opponentCurrentState?.characterKey ? _getCharacterBaseDataForLogic(opponentCurrentState.characterKey, gameDataForLogic) : null; // ИСПОЛЬЗУЕМ _getCharacterBaseDataForLogic effectDescription = ability.descriptionFunction(configToUse, opponentDataForDesc); } + // isDelayed: true используется для эффектов, которые срабатывают ПОСЛЕ следующего действия (например, Сила Природы). + // duration: исходная длительность из данных, turnsLeft: сколько ходов осталось (включая текущий, если !justCast) casterState.activeEffects.push({ id: ability.id, name: ability.name, description: effectDescription, - type: ability.type, turnsLeft: ability.duration, - grantsBlock: !!ability.grantsBlock, justCast: !!ability.isDelayed + type: ability.type, duration: ability.duration, // Сохраняем исходную длительность для отображения в UI или логики + turnsLeft: ability.duration, // Длительность жизни эффекта в ходах владельца + grantsBlock: !!ability.grantsBlock, + isDelayed: !!ability.isDelayed, // Флаг, что эффект отложенный (срабатывает после действия) + justCast: true // Флаг, что эффект только что наложен (для логики processEffects) }); - if (ability.grantsBlock) updateBlockingStatus(casterState); + if (ability.grantsBlock) updateBlockingStatus(casterState); // Обновляем статус блока кастера, если бафф его дает + // Насмешки при применении баффа (selfCastAbility) добавляются в GameInstance перед вызовом applyAbilityEffect + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); } else { - if (addToLogCallback) addToLogCallback(`Эффект "${ability.name}" уже активен на ${casterBaseStats.name}!`, configToUse.LOG_TYPE_INFO); + // Сообщение "уже активен" отправляется из gameInstance перед вызовом applyAbilityEffect } break; - case configToUse.ACTION_TYPE_DISABLE: - tauntMessagePart = ""; // Сбрасываем перед каждым дизейблом - if (targetState.characterKey === 'elena') { - const disableTaunt = getElenaTaunt('opponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState); - if (disableTaunt !== "(Молчание)") tauntMessagePart = ` (${disableTaunt})`; - } + case configToUse.ACTION_TYPE_DISABLE: // Безмолвие, Стан и т.п. + // Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под дизейбл противника - перенесено наверх + // if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') { + // const disableTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState); + // if (disableTaunt !== "(Молчание)") tauntMessagePart = ` (${disableTaunt})`; + // } - if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE && casterState.characterKey === 'elena') { - const effectId = 'fullSilenceByElena'; + // Гипнотический взгляд Елены / Раскол Разума Альмагест (полное безмолвие) + if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) { + const effectId = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest'; + // Не накладываем повторно, если эффект уже есть на цели if (!targetState.activeEffects.some(e => e.id === effectId)) { targetState.activeEffects.push({ id: effectId, name: ability.name, description: ability.description, - type: ability.type, turnsLeft: ability.effectDuration, power: ability.power, isFullSilence: true + type: ability.type, duration: ability.effectDuration, turnsLeft: ability.effectDuration, // Длительность в ходах цели + power: ability.power, // Урон от эффекта (применяется в конце хода цели) + isFullSilence: true, + justCast: true // Эффект только что наложен }); - if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}"! Способности ${targetBaseStats.name} заблокированы на ${ability.effectDuration} хода, и он(а) получает урон!`, configToUse.LOG_TYPE_EFFECT); + if (addToLogCallback) addToLogCallback(`🌀 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}! Способности ${targetBaseStats.name} заблокированы на ${ability.effectDuration} хода, и он(а) получает урон!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); } } + // Эхо Безмолвия Баларда (заглушает случайную абилку) else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') { const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE; const silenceOutcome = success ? 'success' : 'fail'; - const specificSilenceTaunt = getElenaTaunt('opponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState); - tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : ""; + // Реакция цели (Елены) на успех/провал безмолвия Баларда - перенесено наверх, но с context.outcome + // if (targetState.characterKey === 'elena') { // Балард применяет это только на Елену + // const specificSilenceTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState); + // tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : ""; + // } else { + // tauntMessagePart = ""; // Другие персонажи не реагируют на Безмолвие Баларда + // } + + // Нужно получить насмешку с outcome здесь, так как она зависит от результата броска шанса + // Временно сохраняем общую насмешку и получаем специфичную + let specificSilenceTaunt = "(Молчание)"; + if (targetState.characterKey === 'elena') { // Балард применяет это только на Елену + specificSilenceTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', { abilityId: ability.id, outcome: silenceOutcome }, configToUse, gameDataForLogic, currentGameState); + } + tauntMessagePart = (specificSilenceTaunt !== "(Молчание)") ? ` (${specificSilenceTaunt})` : tauntMessagePart; // Используем специфичную, если найдена, иначе общую (хотя общая для этого абилки вряд ли есть) + if (success) { - const targetAbilities = gameDataForLogic.playerAbilities; + const targetAbilities = _getCharacterAbilitiesForLogic(targetState.characterKey, gameDataForLogic); // Глушим абилки цели + // Фильтруем способности, которые еще не заглушены этим типом безмолвия const availableAbilities = targetAbilities.filter(pa => !targetState.disabledAbilities?.some(d => d.abilityId === pa.id) && - !targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) + !targetState.activeEffects?.some(eff => eff.id === `playerSilencedOn_${pa.id}`) // Проверка на эффект заглушения для UI/ProcessEffects ); + if (availableAbilities.length > 0) { const abilityToSilence = availableAbilities[Math.floor(Math.random() * availableAbilities.length)]; - const turns = configToUse.SILENCE_DURATION; - targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); + const turns = configToUse.SILENCE_DURATION; // Длительность из конфига (в ходах цели) + // Добавляем запись о заглушенной способности в disabledAbilities цели + targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); // +1, т.к. длительность уменьшается в конце хода цели + // Добавляем эффект заглушения в activeEffects цели (для UI и ProcessEffects) const silenceEffectIdOnPlayer = `playerSilencedOn_${abilityToSilence.id}`; targetState.activeEffects.push({ id: silenceEffectIdOnPlayer, name: `Безмолвие: ${abilityToSilence.name}`, description: `Способность "${abilityToSilence.name}" временно недоступна.`, - type: configToUse.ACTION_TYPE_DISABLE, turnsLeft: turns + 1 + type: configToUse.ACTION_TYPE_DISABLE, sourceAbilityId: ability.id, // Добавлено sourceAbilityId + duration: turns, turnsLeft: turns + 1, + justCast: true // Эффект только что наложен }); - if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" Елены заблокировано!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" ${targetBaseStats.name} заблокировано на ${turns} хода!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); } else { if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); } @@ -331,74 +486,74 @@ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats, if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); } } - else if (ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE && casterState.characterKey === 'almagest') { - const effectId = 'fullSilenceByAlmagest'; - if (!targetState.activeEffects.some(e => e.id === effectId)) { - targetState.activeEffects.push({ - id: effectId, name: ability.name, description: ability.description, - type: ability.type, turnsLeft: ability.effectDuration, power: ability.power, isFullSilence: true - }); - if (addToLogCallback) addToLogCallback(`🧠 ${casterBaseStats.name} применяет "${ability.name}"! Способности ${targetBaseStats.name} заблокированы на ${ability.effectDuration} хода, и он(а) получает урон!${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); - } - } break; - case configToUse.ACTION_TYPE_DEBUFF: - tauntMessagePart = ""; - if (targetState.characterKey === 'elena') { - const debuffTaunt = getElenaTaunt('opponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState); - if (debuffTaunt !== "(Молчание)") tauntMessagePart = ` (${debuffTaunt})`; - } + case configToUse.ACTION_TYPE_DEBUFF: // Ослабления, DoT и т.п. + // Проверка на насмешку ОТ цели (Елены или Альмагест), если она попадает под дебафф противника - перенесено наверх + // if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') { + // const debuffTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAction', {abilityId: ability.id}, configToUse, gameDataForLogic, currentGameState); + // if (debuffTaunt !== "(Молчание)") tauntMessagePart = ` (${debuffTaunt})`; + // } + + // Печать Слабости Елены / Проклятие Увядания Альмагест (сжигание ресурса) if (ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF) { - const effectIdForDebuff = 'effect_' + ability.id; + const effectIdForDebuff = 'effect_' + ability.id; // Уникальный ID эффекта на цели + // Не накладываем повторно, если эффект уже есть на цели if (!targetState.activeEffects.some(e => e.id === effectIdForDebuff)) { let effectDescription = ability.description; if (typeof ability.descriptionFunction === 'function') { - effectDescription = ability.descriptionFunction(configToUse, targetBaseStats); + effectDescription = ability.descriptionFunction(configToUse, targetBaseStats); // Описание может зависеть от цели } targetState.activeEffects.push({ id: effectIdForDebuff, name: ability.name, description: effectDescription, type: configToUse.ACTION_TYPE_DEBUFF, sourceAbilityId: ability.id, - turnsLeft: ability.effectDuration, power: ability.power, + duration: ability.effectDuration, turnsLeft: ability.effectDuration, // Длительность в ходах цели + power: ability.power, // Количество сжигаемого ресурса в ход (применяется в конце хода цели) + justCast: true // Эффект только что наложен }); if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Ресурс будет сжигаться.${tauntMessagePart}`, configToUse.LOG_TYPE_EFFECT); + } else { + if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!${tauntMessagePart}`, configToUse.LOG_TYPE_INFO); } } break; - case configToUse.ACTION_TYPE_DRAIN: - if (casterState.characterKey === 'balard') { + case configToUse.ACTION_TYPE_DRAIN: // Похищение Света Баларда (наносит урон, вытягивает ресурс, лечит кастера) + if (casterState.characterKey === 'balard') { // Это способность только Баларда let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0; - tauntMessagePart = ""; - if (targetState.characterKey === 'elena') { - const drainTaunt = getElenaTaunt('opponentAction', { abilityId: ability.id }, configToUse, gameDataForLogic, currentGameState); - if (drainTaunt !== "(Молчание)") tauntMessagePart = ` (${drainTaunt})`; - } + // tauntMessagePart уже получена в начале функции + // Сначала урон от способности if (ability.powerDamage > 0) { let baseDamageDrain = ability.powerDamage; + // Проверка на блок цели if (targetState.isBlocking) { baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION); let blockDrainTaunt = ""; - if (targetState.characterKey === 'elena') { - blockDrainTaunt = getElenaTaunt('opponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState); + // Реакция цели (Елены/Альмагест) на блок урона от дрейна + if (targetState.characterKey === 'elena' || targetState.characterKey === 'almagest') { + blockDrainTaunt = getRandomTaunt(targetState.characterKey, 'onOpponentAttackBlocked', {}, configToUse, gameDataForLogic, currentGameState); if (blockDrainTaunt !== "(Молчание)") blockDrainTaunt = ` (${blockDrainTaunt})`; } - if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует часть урона от "${ability.name}"! Урон снижен до ${baseDamageDrain}.${blockDrainTaunt}`, configToUse.LOG_TYPE_BLOCK); + if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует часть урона от "${ability.name}" от ${casterBaseStats.name}! Урон снижен до ${baseDamageDrain}.${blockDrainTaunt}`, configToUse.LOG_TYPE_BLOCK); } damageDealtDrain = Math.max(0, baseDamageDrain); - targetState.currentHp = Math.max(0, targetState.currentHp - damageDealtDrain); + // ИСПРАВЛЕНО: Округляем результат вычитания HP + targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damageDealtDrain)); } + // Затем вытягивание ресурса и лечение кастера const potentialDrain = ability.powerManaDrain; const actualDrain = Math.min(potentialDrain, targetState.currentResource); if (actualDrain > 0) { - targetState.currentResource -= actualDrain; + // ИСПРАВЛЕНО: Округляем результат вычитания ресурса + targetState.currentResource = Math.max(0, Math.round(targetState.currentResource - actualDrain)); manaDrained = actualDrain; const potentialHeal = Math.floor(manaDrained * ability.powerHealthGainFactor); const actualHealGain = Math.min(potentialHeal, casterBaseStats.maxHp - casterState.currentHp); - casterState.currentHp += actualHealGain; + // ИСПРАВЛЕНО: Округляем результат прибавления HP + casterState.currentHp = Math.round(casterState.currentHp + actualHealGain); healthGained = actualHealGain; } @@ -407,12 +562,17 @@ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats, if (manaDrained > 0) { logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name} и исцеляется на ${healthGained} HP!`; } else if (damageDealtDrain > 0) { - logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; - } else { - logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения, эффект не сработал!`; + logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; // Урон прошел, но ресурс не вытянулся + } else { // Ни урона, ни вытягивания ресурса + // ИСПРАВЛЕНО: targetBaseStats.resourceName -> targetState.resourceName (или defenderBaseStats.resourceName, если он передается) + logMsgDrain += `У ${targetBaseStats.name} нет ${targetBaseStats.resourceName} для похищения.`; // Оставляем targetBaseStats.resourceName, т.к. он точнее для лога + // Если урон был 0, и ресурса нет, можно уточнить лог + if(damageDealtDrain === 0 && potentialDrain > 0) logMsgDrain += ` Урон не прошел или равен нулю, ресурс не похищен.`; } - logMsgDrain += tauntMessagePart; + logMsgDrain += tauntMessagePart; // Добавляем насмешку цели, если была if (addToLogCallback) addToLogCallback(logMsgDrain, manaDrained > 0 || damageDealtDrain > 0 ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO); + } else { + console.warn(`applyAbilityEffect: Drain type ability ${ability?.name} used by non-Balard character ${casterState.characterKey}`); } break; @@ -421,88 +581,145 @@ function applyAbilityEffect(ability, casterState, targetState, casterBaseStats, } } +/** + * Логика принятия решения для AI (Балард). + * @param {object} currentGameState - Текущее состояние игры. + * @param {object} gameDataForLogic - Данные игры. + * @param {object} configToUse - Конфигурация игры. + * @param {function} addToLogCallback - Функция для добавления лога. + * @returns {object} Объект с действием AI ({ actionType: 'attack' | 'ability', ability?: object }). + */ function decideAiAction(currentGameState, gameDataForLogic = gameData, configToUse, addToLogCallback) { - const opponentState = currentGameState.opponent; - const playerState = currentGameState.player; + const opponentState = currentGameState.opponent; // AI Балард всегда в слоте opponent + const playerState = currentGameState.player; // Игрок всегда в слоте player (в AI режиме) + // Убеждаемся, что это AI Балард if (opponentState.characterKey !== 'balard') { console.warn("[AI DEBUG] decideAiAction called for non-Balard opponent. This should not happen."); return { actionType: 'pass', logMessage: { message: `${opponentState.name} (не AI) пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; } - const isBalardFullySilencedByElena = opponentState.activeEffects.some( - eff => eff.id === 'fullSilenceByElena' && eff.turnsLeft > 0 + // Проверка полного безмолвия Баларда (от Гипнотического Взгляда Елены или Раскола Разума Альмагест) + const isBalardFullySilenced = opponentState.activeEffects.some( + eff => eff.isFullSilence && eff.turnsLeft > 0 ); - if (isBalardFullySilencedByElena) { - if (addToLogCallback) addToLogCallback(`😵 ${opponentState.name} под действием "Гипнотического взгляда"! Атакует в смятении.`, configToUse.LOG_TYPE_EFFECT); + if (isBalardFullySilenced) { + // AI под полным безмолвием просто атакует + // Лог о безмолвии и атаке в смятении добавляется в processAiTurn перед вызовом performAttack. + // decideAiAction просто возвращает действие. return { actionType: 'attack' }; } const availableActions = []; - const opponentAbilities = gameDataForLogic.opponentAbilities; + const opponentAbilities = gameDataForLogic.opponentAbilities; // Способности Баларда + + // Проверяем доступность способностей AI и добавляем их в список возможных действий с весом + // Вес определяет приоритет: выше вес -> выше шанс выбора (после сортировки) const healAbility = opponentAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_HEAL); if (healAbility && opponentState.currentResource >= healAbility.cost && + (opponentState.abilityCooldowns?.[healAbility.id] || 0) <= 0 && // Проверка общего КД (хотя у Баларда могут быть только спец. КД) healAbility.condition(opponentState, playerState, currentGameState, configToUse)) { availableActions.push({ weight: 80, type: 'ability', ability: healAbility, requiresSuccessCheck: true, successRate: healAbility.successRate }); } const silenceAbility = opponentAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_SILENCE); if (silenceAbility && opponentState.currentResource >= silenceAbility.cost && - (opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) && - (!opponentState.abilityCooldowns || opponentState.abilityCooldowns[silenceAbility.id] === undefined || opponentState.abilityCooldowns[silenceAbility.id] <=0) && + (opponentState.silenceCooldownTurns === undefined || opponentState.silenceCooldownTurns <= 0) && // Проверка спец. КД безмолвия + (opponentState.abilityCooldowns?.[silenceAbility.id] || 0) <= 0 && // Проверка общего КД silenceAbility.condition(opponentState, playerState, currentGameState, configToUse)) { const playerHpPercent = (playerState.currentHp / playerState.maxHp) * 100; - if (playerHpPercent > (configToUse.PLAYER_HP_BLEED_THRESHOLD_PERCENT || 40)) { + // Балард предпочитает безмолвие, если HP Елены не слишком низкое (позволяет ей лечиться, чтобы игра длилась дольше) + if (playerHpPercent > (configToUse.PLAYER_HP_BLEED_THRESHOLD_PERCENT || 60)) { // Используем порог для текстов Елены как пример availableActions.push({ weight: 60, type: 'ability', ability: silenceAbility, requiresSuccessCheck: true, successRate: configToUse.SILENCE_SUCCESS_RATE }); } } const drainAbility = opponentAbilities.find(a => a.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN); if (drainAbility && opponentState.currentResource >= drainAbility.cost && - (opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) && - (!opponentState.abilityCooldowns || opponentState.abilityCooldowns[drainAbility.id] === undefined || opponentState.abilityCooldowns[drainAbility.id] <=0) && + (opponentState.manaDrainCooldownTurns === undefined || opponentState.manaDrainCooldownTurns <= 0) && // Проверка спец. КД дрейна + (opponentState.abilityCooldowns?.[drainAbility.id] || 0) <= 0 && // Проверка общего КД drainAbility.condition(opponentState, playerState, currentGameState, configToUse)) { availableActions.push({ weight: 50, type: 'ability', ability: drainAbility }); } + // Базовая атака - всегда доступна как запасной вариант с низким весом availableActions.push({ weight: 30, type: 'attack' }); + // Если по какой-то причине список доступных действий пуст (не должно быть, т.к. атака всегда есть) if (availableActions.length === 0) { return { actionType: 'pass', logMessage: { message: `${opponentState.name} не может совершить действие.`, type: configToUse.LOG_TYPE_INFO } }; } + // Сортируем действия по весу в порядке убывания availableActions.sort((a, b) => b.weight - a.weight); + // Перебираем действия в порядке приоритета и выбираем первое возможное for (const action of availableActions) { - if (action.requiresSuccessCheck) { - if (Math.random() < action.successRate) { - return { actionType: action.type, ability: action.ability }; + if (action.type === 'ability') { + // Если способность требует проверки успеха (например, Безмолвие Баларда) + if (action.requiresSuccessCheck) { + if (Math.random() < action.successRate) { + // Успех, добавляем лог о попытке (чтобы было видно, что AI пытался) + if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} пытается использовать "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO); + return { actionType: action.type, ability: action.ability }; // Успех, выбираем эту способность + } else { + // Провал, добавляем лог о провале и переходим к следующему возможному действию в цикле + if (addToLogCallback) addToLogCallback(`💨 Попытка ${opponentState.name} использовать "${action.ability.name}" провалилась!`, configToUse.LOG_TYPE_INFO); + continue; // Пробуем следующее действие в списке + } } else { - if (addToLogCallback) addToLogCallback(`💨 ${opponentState.name} пытается использовать "${action.ability.name}", но терпит неудачу!`, configToUse.LOG_TYPE_INFO); - continue; + // Нет проверки успеха, добавляем лог о попытке и выбираем способность + if (addToLogCallback) addToLogCallback(`⭐ ${opponentState.name} использует "${action.ability.name}"...`, configToUse.LOG_TYPE_INFO); + return { actionType: action.type, ability: action.ability }; } - } else { - return { actionType: action.type, ability: action.ability }; + } else if (action.type === 'attack') { + // Атака - всегда возможна (если нет полного безмолвия, проверено выше) + if (addToLogCallback) addToLogCallback(`🦶 ${opponentState.name} готовится к атаке...`, configToUse.LOG_TYPE_INFO); + return { actionType: 'attack' }; } + // 'pass' не должен быть в доступных действиях, если атака всегда доступна } - return { actionType: 'attack' }; + + // Если все попытки выбрать способность или атаку провалились (очень маловероятно, если атака всегда в списке), пропуск хода + console.warn("[AI DEBUG] AI failed to select any action. Defaulting to pass."); + return { actionType: 'pass', logMessage: { message: `${opponentState.name} не смог выбрать подходящее действие. Пропускает ход.`, type: configToUse.LOG_TYPE_INFO } }; } +/** + * Внутренняя проверка условий конца игры (основано на HP). + * @param {object} currentGameState - Текущее состояние игры. + * @param {object} configToUse - Конфигурация игры. + * @param {object} gameDataForLogic - Данные игры. + * @returns {boolean} true, если игра окончена, иначе false. + */ function checkGameOverInternal(currentGameState, configToUse, gameDataForLogic = gameData) { + // Проверка на конец игры происходит только если gameState существует и игра еще не помечена как оконченная if (!currentGameState || currentGameState.isGameOver) return currentGameState ? currentGameState.isGameOver : true; + + // Убеждаемся, что оба бойца определены в gameState и не являются плейсхолдерами + // Проверка maxHp > 0 в gameState.opponent гарантирует, что оппонент не плейсхолдер + if (!currentGameState.player || !currentGameState.opponent || currentGameState.opponent.maxHp <= 0) { + // Если один из бойцов не готов (например, PvP игра ожидает второго игрока), игра не может закончиться по HP + return false; + } + + const playerDead = currentGameState.player.currentHp <= 0; const opponentDead = currentGameState.opponent.currentHp <= 0; + + // Игра окончена, если один или оба бойца мертвы return playerDead || opponentDead; } +// Экспортируем все функции, которые используются в других модулях module.exports = { processEffects, processDisabledAbilities, processPlayerAbilityCooldowns, updateBlockingStatus, - getElenaTaunt, + getRandomTaunt, // Экспортируем переименованную функцию performAttack, applyAbilityEffect, decideAiAction, diff --git a/server_modules/gameManager.js b/server_modules/gameManager.js index b6c28ac..a2ad06a 100644 --- a/server_modules/gameManager.js +++ b/server_modules/gameManager.js @@ -1,270 +1,520 @@ // /server_modules/gameManager.js -const { v4: uuidv4 } = require('uuid'); -const GameInstance = require('./gameInstance'); -const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient +const { v4: uuidv4 } = require('uuid'); // Убедитесь, что uuidv4 установлен: npm install uuid +const GameInstance = require('./gameInstance'); // Убедитесь, что GameInstance экспортируется из gameInstance.js +const gameData = require('./data'); // Нужен для getAvailablePvPGamesListForClient и данных персонажей const GAME_CONFIG = require('./config'); // Нужен для GAME_CONFIG.PLAYER_ID и других констант class GameManager { constructor(io) { - this.io = io; - this.games = {}; // { gameId: GameInstance } - this.socketToGame = {}; // { socket.id: gameId } - this.pendingPvPGames = []; // [gameId] - ID игр, ожидающих второго игрока в PvP - this.userToPendingGame = {}; // { userId: gameId } или { socketId: gameId } - для отслеживания созданных ожидающих игр + this.io = io; // Ссылка на Socket.IO сервер для широковещательных рассылок + this.games = {}; // { gameId: GameInstance } - Все активные или ожидающие игры + this.userIdentifierToGameId = {}; // { userId|socketId: gameId } - Какому пользователю какая игра соответствует (более стабильно, чем socket.id) + this.pendingPvPGames = []; // [gameId] - ID PvP игр, ожидающих второго игрока + + // Навешиваем обработчик события 'gameOver' на Socket.IO сервер + // Это событие исходит от экземпляра GameInstance при завершении игры (по HP или дисконнекту) + // Мы слушаем его здесь, чтобы GameManager мог очистить ссылки. + // Примечание: Это событие отправляется всем в комнате игры. GameManager слушает его через io.sockets.sockets.on, + // но удобнее слушать его на уровне io, если возможно, или добавить специальный emit из GameInstance. + // Текущая архитектура (GameInstance напрямую вызывает io.to(...).emit('gameOver', ...)) уже рабочая. + // GameManager сам должен отреагировать на завершение, проверяя gameState.isGameOver после каждого действия/хода. + // Или GameInstance должен вызвать специальный метод GameManager при gameOver. + // Давайте сделаем GameInstance вызывать метод GameManager при gameOver. } + /** + * Удаляет предыдущую ожидающую игру пользователя, если таковая существует. + * Это предотвращает создание множества пустых игр одним пользователем. + * @param {string} currentSocketId - ID текущего сокета. + * @param {string|number} identifier - userId или socketId пользователя. + * @param {string|null} excludeGameId - ID игры, которую НЕ нужно удалять (например, если пользователь присоединяется к своей же игре). + */ _removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) { - const keyToUse = identifier || currentSocketId; - const oldPendingGameId = this.userToPendingGame[keyToUse]; + // Ищем игру по идентификатору пользователя + const oldPendingGameId = this.userIdentifierToGameId[identifier]; - if (oldPendingGameId && oldPendingGameId !== excludeGameId) { + // Проверяем, что нашли игру, она не исключена, и она все еще существует в списке игр + if (oldPendingGameId && oldPendingGameId !== excludeGameId && this.games[oldPendingGameId]) { const gameToRemove = this.games[oldPendingGameId]; - if (gameToRemove && gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) { - const playersInOldGame = Object.values(gameToRemove.players); - const isOwnerBySocket = playersInOldGame.length === 1 && playersInOldGame[0].socket.id === currentSocketId; - const isOwnerByUserId = identifier && gameToRemove.ownerUserId === identifier; + // Проверяем, что игра является ожидающей PvP игрой с одним игроком + if (gameToRemove.mode === 'pvp' && gameToRemove.playerCount === 1 && this.pendingPvPGames.includes(oldPendingGameId)) { + // Проверяем, что этот пользователь является владельцем этой ожидающей игры + // Владелец в pendingPvPGames - это всегда тот, кто ее создал (первый игрок в слоте PLAYER_ID) + const oldOwnerInfo = Object.values(gameToRemove.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - if (isOwnerBySocket || isOwnerByUserId) { - console.log(`[GameManager] Пользователь ${keyToUse} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`); + // Проверяем, что владелец игры существует и его идентификатор совпадает + if (oldOwnerInfo && (oldOwnerInfo.identifier === identifier)) { + console.log(`[GameManager] Пользователь ${identifier} (сокет: ${currentSocketId}) создал/присоединился к новой игре. Удаляем его предыдущую ожидающую игру: ${oldPendingGameId}`); - delete this.games[oldPendingGameId]; - const pendingIndex = this.pendingPvPGames.indexOf(oldPendingGameId); - if (pendingIndex > -1) this.pendingPvPGames.splice(pendingIndex, 1); + // Используем централизованную функцию очистки + this._cleanupGame(oldPendingGameId, 'replaced_by_new_game'); - if (playersInOldGame.length === 1 && this.socketToGame[playersInOldGame[0].socket.id] === oldPendingGameId) { - delete this.socketToGame[playersInOldGame[0].socket.id]; - } - delete this.userToPendingGame[keyToUse]; - - this.broadcastAvailablePvPGames(); + // Оповещаем клиентов об обновленном списке игр (уже внутри _cleanupGame) + // this.broadcastAvailablePvPGames(); } - } else if (oldPendingGameId === excludeGameId) { - // Это та же игра, к которой игрок присоединяется, ничего не делаем } else { - delete this.userToPendingGame[keyToUse]; + // Если игра не соответствует критериям ожидающей игры, но идентификатор был связан с ней, + // это может означать, что игра уже началась или была завершена. + // Просто очищаем ссылку, если она не ведет в исключенную игру. + // Идентификатор должен был быть очищен из userIdentifierToGameId при старте или завершении игры. + // На всякий случай убеждаемся, что мы не удаляем ссылку на игру, к которой только что присоединились. + if (this.userIdentifierToGameId[identifier] !== excludeGameId) { + console.warn(`[GameManager] Удаление потенциально некорректной ссылки userIdentifierToGameId[${identifier}] на игру ${oldPendingGameId}.`); + delete this.userIdentifierToGameId[identifier]; + } } } + // Если oldPendingGameId не найдена, или она равна excludeGameId, ничего не делаем. } - createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', userId = null) { - const identifier = userId || socket.id; + + /** + * Создает новую игру. + * @param {object} socket - Сокет игрока, создающего игру. + * @param {string} [mode='ai'] - Режим игры ('ai' или 'pvp'). + * @param {string} [chosenCharacterKey='elena'] - Выбранный персонаж для первого игрока в PvP. + * @param {string|number} identifier - ID пользователя (userId или socketId). + */ + createGame(socket, mode = 'ai', chosenCharacterKey = 'elena', identifier) { + // Удаляем старые ожидающие игры этого пользователя, прежде чем создавать новую this._removePreviousPendingGames(socket.id, identifier); - const gameId = uuidv4(); - const game = new GameInstance(gameId, this.io, mode); - if (userId) game.ownerUserId = userId; - this.games[gameId] = game; + // Проверяем, не находится ли пользователь уже в какой-то игре (активной или ожидающей) + // Проверяем наличие ссылки на игру по идентификатору пользователя + if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { + console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на создание.`); + socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); + // Можно попробовать отправить состояние текущей игры пользователю + this.handleRequestGameState(socket, identifier); + return; + } - // В AI режиме игрок всегда Елена, в PvP - тот, кого выбрали + + const gameId = uuidv4(); // Генерируем уникальный ID для игры + // Передаем ссылку на GameManager в GameInstance, чтобы он мог вызвать _notifyGameEnded + const game = new GameInstance(gameId, this.io, mode, this); // <-- ПЕРЕДАЕМ GameManager + game.ownerIdentifier = identifier; // Сохраняем идентификатор создателя + this.games[gameId] = game; // Добавляем игру в список активных игр + + // В AI режиме игрок всегда Елена, в PvP - тот, кого выбрали при создании const charKeyForInstance = (mode === 'pvp') ? chosenCharacterKey : 'elena'; - if (game.addPlayer(socket, charKeyForInstance)) { - this.socketToGame[socket.id] = gameId; - console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${socket.userData?.username || socket.id} (userId: ${userId}, выбран: ${charKeyForInstance})`); + // Добавляем игрока в созданный экземпляр игры, передавая идентификатор + // GameInstance.addPlayer принимает socket, chosenCharacterKey, identifier + if (game.addPlayer(socket, charKeyForInstance, identifier)) { + this.userIdentifierToGameId[identifier] = gameId; // Связываем идентификатор пользователя с этой игрой + console.log(`[GameManager] Игра создана: ${gameId} (режим: ${mode}) игроком ${identifier} (сокет: ${socket.id}, выбран: ${charKeyForInstance})`); - const assignedPlayerId = game.players[socket.id]?.id; + // Уведомляем игрока, что игра создана, и передаем его технический ID слота + const assignedPlayerId = game.players[socket.id]?.id; // ID слота все еще берем из playerInfo по socket.id if (!assignedPlayerId) { - delete this.games[gameId]; if(this.socketToGame[socket.id] === gameId) delete this.socketToGame[socket.id]; - socket.emit('gameError', { message: 'Ошибка сервера при создании игры (не удалось назначить ID игрока).' }); return; + // Если по какой-то причине не удалось назначить ID игрока, удаляем игру и отправляем ошибку + // Используем централизованную функцию очистки + this._cleanupGame(gameId, 'player_add_failed'); + console.error(`[GameManager] Ошибка при создании игры ${gameId}: Не удалось назначить ID игрока сокету ${socket.id} (идентификатор ${identifier}).`); + socket.emit('gameError', { message: 'Ошибка сервера при создании игры.' }); + return; } socket.emit('gameCreated', { gameId: gameId, mode: mode, yourPlayerId: assignedPlayerId }); - if (mode === 'pvp') { - if (!this.pendingPvPGames.includes(gameId)) this.pendingPvPGames.push(gameId); - this.userToPendingGame[identifier] = gameId; - this.broadcastAvailablePvPGames(); + + // --- Логика старта игры --- + // Если игра AI и теперь с 1 игроком, или PvP и теперь с 2 игроками, запускаем ее немедленно + if ((game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2)) { + console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`); + // Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены. + const isInitialized = game.initializeGame(); + if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние + game.startGame(); // Запускаем игру + } else { + console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`); + // initializeGame уже должен был добавить ошибку в лог игры и отправить gameError клиентам + // Возможно, стоит вызвать cleanupGame здесь при ошибке инициализации + this._cleanupGame(gameId, 'initialization_failed'); + } + + // Если игра PvP и только что заполнилась, удаляем ее из списка ожидающих + // Идентификаторы игроков остаются связанными с игрой в userIdentifierToGameId до ее завершения. + if (game.mode === 'pvp' && game.playerCount === 2) { + const gameIndex = this.pendingPvPGames.indexOf(gameId); + if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1); + // Связи userIdentifierToGameId[identifier] НЕ УДАЛЯЕМ! Они нужны для активной игры. + this.broadcastAvailablePvPGames(); // Обновляем список у всех клиентов + } + + + } else if (mode === 'pvp' && game.playerCount === 1) { + // Если игра PvP и ожидает второго игрока, добавляем ее в список ожидающих + if (!this.pendingPvPGames.includes(gameId)) { + this.pendingPvPGames.push(gameId); // Добавляем ID игры в список ожидающих + } + // userIdentifierToGameId для создателя уже установлен выше + + // Частичная инициализация gameState для отображения Player 1 на UI ожидания + // initializeGame вызывается при playerCount === 1 в GameInstance + game.initializeGame(); + + this.broadcastAvailablePvPGames(); // Обновляем список у всех } + // --- КОНЕЦ Логики старта игры --- + + } else { - delete this.games[gameId]; - if (this.socketToGame[socket.id] === gameId) delete this.socketToGame[socket.id]; - // Сообщение об ошибке отправляется из game.addPlayer + // Если не удалось добавить игрока в GameInstance (например, уже 2 игрока - хотя проверили выше), удаляем игру + // Используем централизованную функцию очистки + this._cleanupGame(gameId, 'player_add_failed'); + // GameInstance.addPlayer уже отправил ошибку клиенту + console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}. Игра удалена.`); } } - joinGame(socket, gameId, userId = null) { - const identifier = userId || socket.id; - const game = this.games[gameId]; + /** + * Присоединяет игрока к существующей игре по ID. + * @param {object} socket - Сокет игрока. + * @param {string} gameId - ID игры, к которой нужно присоединиться. + * @param {string|number} identifier - ID пользователя (userId). + */ + joinGame(socket, gameId, identifier) { // В joinGame всегда передается userId, т.к. PvP требует логина + const game = this.games[gameId]; // Находим игру по ID + // Проверки перед присоединением if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; } - if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'Эта игра не является PvP игрой.' }); return; } + if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться как к PvP.' }); return; } if (game.playerCount >= 2) { socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return; } - if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return;} + // Проверка, не находится ли пользователь уже в какой-то игре + if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]] && this.userIdentifierToGameId[identifier] !== gameId) { + console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на присоединение.`); + socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); + this.handleRequestGameState(socket, identifier); // Попробуем отправить состояние текущей игры + return; + } + if (game.players[socket.id]) { socket.emit('gameError', { message: 'Вы уже в этой игре.' }); return;} // Проверка на повторное присоединение по текущему сокету (хотя userIdentifierToGameId должен это предотвратить) + // Удаляем старые ожидающие игры этого пользователя, исключая текущую игру, к которой присоединяемся this._removePreviousPendingGames(socket.id, identifier, gameId); // addPlayer в GameInstance сам определит персонажа для второго игрока на основе первого - if (game.addPlayer(socket)) { - this.socketToGame[socket.id] = gameId; - console.log(`[GameManager] Игрок ${socket.userData?.username || socket.id} (userId: ${userId}) присоединился к PvP игре ${gameId}`); + // GameInstance.addPlayer принимает socket, chosenCharacterKey (null для присоединения), identifier + if (game.addPlayer(socket, null, identifier)) { // chosenCharacterKey для присоединяющегося игрока не нужен, передаем null + this.userIdentifierToGameId[identifier] = gameId; // Связываем идентификатор пользователя с этой игрой + console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) присоединился к PvP игре ${gameId}`); - const gameIndex = this.pendingPvPGames.indexOf(gameId); - if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1); - - if (game.ownerUserId && this.userToPendingGame[game.ownerUserId] === gameId) { - delete this.userToPendingGame[game.ownerUserId]; - } else { - const firstPlayerSocketId = Object.keys(game.players).find(sId => game.players[sId].id === GAME_CONFIG.PLAYER_ID && game.players[sId].socket.id !== socket.id); - if (firstPlayerSocketId && this.userToPendingGame[firstPlayerSocketId] === gameId) { - delete this.userToPendingGame[firstPlayerSocketId]; + // --- Логика старта игры --- + // Если игра PvP и теперь с 2 игроками, запускаем ее немедленно + if (game.mode === 'pvp' && game.playerCount === 2) { + console.log(`[GameManager] Игра ${gameId} готова к старту. Инициализация и запуск.`); + // Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены. + const isInitialized = game.initializeGame(); + if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние + game.startGame(); // Запускаем игру + } else { + console.error(`[GameManager] Не удалось запустить игру ${gameId}: initializeGame вернул false или gameState некорректен после инициализации.`); + // initializeGame уже должен был добавить ошибку в лог игры и отправить gameError клиентам + // Возможно, стоит вызвать cleanupGame здесь при ошибке инициализации + this._cleanupGame(gameId, 'initialization_failed'); } + + // Если игра PvP и только что заполнилась, удаляем ее из списка ожидающих + const gameIndex = this.pendingPvPGames.indexOf(gameId); + if (gameIndex > -1) this.pendingPvPGames.splice(gameIndex, 1); + + // Связи userIdentifierToGameId[identifier] НЕ УДАЛЯЕМ! Они нужны для активной игры. + // ownerIdentifier игры (идентификатор создателя) также остается. + + this.broadcastAvailablePvPGames(); // Обновляем список у всех клиентов } - this.broadcastAvailablePvPGames(); + // --- КОНЕЦ Логики старта игры --- + + } else { // Сообщение об ошибке отправляется из game.addPlayer + console.warn(`[GameManager] Не удалось добавить игрока ${socket.id} (идентификатор ${identifier}) в игру ${gameId}.`); } } - findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', userId = null) { - const identifier = userId || socket.id; + /** + * Ищет случайную ожидающую PvP игру и присоединяет игрока к ней. + * Если подходящих игр нет, создает новую ожидающую игру. + * @param {object} socket - Сокет игрока. + * @param {string} [chosenCharacterKeyForCreation='elena'] - Выбранный персонаж, если придется создавать новую игру. + * @param {string|number} identifier - ID пользователя (userId). + */ + findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) { // В findRandomGame всегда передается userId + // Удаляем старые ожидающие игры этого пользователя this._removePreviousPendingGames(socket.id, identifier); + // Проверяем, не находится ли пользователь уже в какой-то игре + if (this.userIdentifierToGameId[identifier] && this.games[this.userIdentifierToGameId[identifier]]) { + console.warn(`[GameManager] Пользователь ${identifier} (сокет: ${socket.id}) уже в игре ${this.userIdentifierToGameId[identifier]}. Игнорируем запрос на поиск.`); + socket.emit('gameError', { message: 'Вы уже находитесь в активной или ожидающей игре.' }); + this.handleRequestGameState(socket, identifier); // Попробуем отправить состояние текущей игры + return; + } + let gameIdToJoin = null; - // Персонаж, которого мы бы хотели видеть у оппонента (зеркальный нашему выбору) + // Персонаж, которого мы бы хотели видеть у оппонента (зеркальный нашему выбору для создания) const preferredOpponentKey = chosenCharacterKeyForCreation === 'elena' ? 'almagest' : 'elena'; - // Сначала ищем игру, где первый игрок выбрал "зеркального" персонажа + // Ищем свободную игру в списке ожидающих for (const id of this.pendingPvPGames) { const pendingGame = this.games[id]; - if (pendingGame && pendingGame.playerCount === 1 && pendingGame.mode === 'pvp') { - const firstPlayerInfo = Object.values(pendingGame.players)[0]; - const isMyOwnGame = (userId && pendingGame.ownerUserId === userId) || (firstPlayerInfo.socket.id === socket.id); - if (isMyOwnGame) continue; - + // Проверяем, что игра существует, PvP, в ней только 1 игрок и это НЕ игра, которую создал сам текущий пользователь + // Игрок не должен присоединяться к игре, которую создал сам. + if (pendingGame && pendingGame.mode === 'pvp' && pendingGame.playerCount === 1 && pendingGame.ownerIdentifier !== identifier) { + // Нашли потенциальную игру. Проверяем предпочтительного оппонента. + const firstPlayerInfo = Object.values(pendingGame.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); // В ожидающей игре всегда 1 игрок, он и есть players[0] if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === preferredOpponentKey) { - gameIdToJoin = id; break; - } - } - } - // Если не нашли с предпочтительным оппонентом, ищем любую свободную (не нашу) - if (!gameIdToJoin && this.pendingPvPGames.length > 0) { - for (const id of this.pendingPvPGames) { - const pendingGame = this.games[id]; - if (pendingGame && pendingGame.playerCount === 1 && pendingGame.mode === 'pvp') { - const firstPlayerInfo = Object.values(pendingGame.players)[0]; - const isMyOwnGame = (userId && pendingGame.ownerUserId === userId) || (firstPlayerInfo.socket.id === socket.id); - if (isMyOwnGame) continue; - gameIdToJoin = id; break; + gameIdToJoin = id; // Нашли игру с предпочтительным оппонентом + break; // Выходим из цикла, т.к. нашли лучший вариант } + // Если предпочтительного не нашли в этом цикле, сохраняем ID первой попавшейся (не своей) игры + if (!gameIdToJoin) gameIdToJoin = id; // Сохраняем, но продолжаем искать предпочтительную } } if (gameIdToJoin) { // Присоединяемся к найденной игре. GameInstance.addPlayer сам назначит нужного персонажа второму игроку. - this.joinGame(socket, gameIdToJoin, userId); + console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) нашел игру ${gameIdToJoin} и присоединяется.`); + this.joinGame(socket, gameIdToJoin, identifier); // Используем joinGame, т.к. логика присоединения одинакова } else { // Если свободных игр нет, создаем новую с выбранным персонажем - this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, userId); + console.log(`[GameManager] Игрок ${identifier} (сокет: ${socket.id}) не нашел свободных игр. Создает новую.`); + this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier); // Используем createGame // Клиент получит 'gameCreated', а 'noPendingGamesFound' используется для информационного сообщения + // userIdentifierToGameId уже обновлен в createGame socket.emit('noPendingGamesFound', { message: 'Свободных PvP игр не найдено. Создана новая игра для вас. Ожидайте противника.', - gameId: this.userToPendingGame[identifier], // ID только что созданной игры + gameId: this.userIdentifierToGameId[identifier], // ID только что созданной игры yourPlayerId: GAME_CONFIG.PLAYER_ID // При создании всегда PLAYER_ID }); } } - handlePlayerAction(socketId, actionData) { - const gameIdFromSocket = this.socketToGame[socketId]; - const game = this.games[gameIdFromSocket]; - if (game) { - game.processPlayerAction(socketId, actionData); + /** + * Перенаправляет действие игрока соответствующему экземпляру игры. + * @param {string|number} identifier - ID пользователя (userId или socketId). + * @param {object} actionData - Данные о действии. + */ + handlePlayerAction(identifier, actionData) { // Теперь принимаем identifier + const gameId = this.userIdentifierToGameId[identifier]; // Находим ID игры по идентификатору пользователя + const game = this.games[gameId]; // Находим экземпляр игры + + if (game && game.players) { + // Находим текущий сокет ID пользователя в списке игроков этой игры + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); + const currentSocketId = playerInfo?.socket?.id; + + if (playerInfo && currentSocketId) { + // Проверяем, что сокет с этим ID еще подключен. + // Это дополнительная проверка, чтобы не обрабатывать действия от "зомби"-сокетов + const actualSocket = this.io.sockets.sockets.get(currentSocketId); + + if (actualSocket && actualSocket.connected) { + // Передаем действие экземпляру игры, используя ТЕКУЩИЙ Socket ID + game.processPlayerAction(currentSocketId, actionData); // processPlayerAction в GameInstance использует socketId + } else { + // Если сокет не найден или не подключен, это может быть старое действие от отключившегося сокета + console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его текущий сокет (${currentSocketId}) не найден или отключен.`); + // Не отправляем ошибку клиенту, так как он, вероятно, уже отключен или переподключается + // Клиент получит gameNotFound при следующем запросе состояния или gameError, если игра еще активна + } + } else { + // Игрок не найден в списке players этой игры по идентификатору + console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}) для игры ${gameId}, но его запись не найдена в game.players.`); + // В таком случае, возможно, состояние userIdentifierToGameId некорректно. + // Удаляем некорректную ссылку. + delete this.userIdentifierToGameId[identifier]; + // Оповещаем клиента, что игра не найдена (он должен будет запросить состояние) + const playerSocket = this.io.sockets.sockets.get(identifier); // Попробуем найти сокет по идентификатору (если он был socket.id) + if (!playerSocket && playerInfo?.socket) { // Если не нашли по identifier, попробуем по сокету из playerInfo + playerSocket = playerInfo.socket; + } + if (playerSocket) { + playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' }); + } + } } else { - const playerSocket = this.io.sockets.sockets.get(socketId); - if (playerSocket) playerSocket.emit('gameError', { message: 'Ошибка: игровая сессия потеряна для этого действия.' }); + // Если игра не найдена по userIdentifierToGameId[identifier] + console.warn(`[GameManager] Игрок ${identifier} отправил действие (${actionData?.actionType}), но его игра (ID: ${gameId}) не найдена в GameManager.`); + // Удаляем некорректную ссылку + delete this.userIdentifierToGameId[identifier]; + // Отправляем gameNotFound клиенту, если можем его найти (по identifier, если это socket.id) + const playerSocket = this.io.sockets.sockets.get(identifier); + if (playerSocket) { + playerSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена или завершена.' }); + } } } - handleDisconnect(socketId, userId = null) { - const identifier = userId || socketId; - const gameId = this.socketToGame[socketId]; + /** + * Обрабатывает отключение сокета игрока. + * Вызывается из bc.js при событии 'disconnect'. + * @param {string} socketId - ID отключившегося сокета. + * @param {string|number} identifier - ID пользователя (userId или socketId). + */ + handleDisconnect(socketId, identifier) { // Принимаем и socketId, и identifier + // Ищем игру по идентификатору пользователя (более надежный способ после переподключения) + const gameId = this.userIdentifierToGameId[identifier]; + const game = this.games[gameId]; - if (gameId && this.games[gameId]) { - const game = this.games[gameId]; - const playerInfo = game.players[socketId]; - const username = playerInfo?.socket?.userData?.username || socketId; - console.log(`[GameManager] Игрок ${username} (socket: ${socketId}, userId: ${userId}) отключился от игры ${gameId}.`); - game.removePlayer(socketId); + // Если игра найдена и в ней есть игрок с этим идентификатором (или сокетом) + if (game && game.players) { + // Находим информацию об игроке по идентификатору + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); - if (game.playerCount === 0) { - console.log(`[GameManager] Игра ${gameId} пуста и будет удалена (после дисконнекта).`); - delete this.games[gameId]; - const gameIndexPending = this.pendingPvPGames.indexOf(gameId); - if (gameIndexPending > -1) this.pendingPvPGames.splice(gameIndexPending, 1); - for (const key in this.userToPendingGame) { - if (this.userToPendingGame[key] === gameId) delete this.userToPendingGame[key]; + if (playerInfo) { + console.log(`[GameManager] Игрок ${identifier} (сокет: ${socketId}) отключился. В игре ${gameId}.`); + + // Удаляем игрока из экземпляра игры, передавая Socket ID, который отключился + // GameInstance.removePlayer принимает socketId + game.removePlayer(socketId); // Передаем socketId для удаления конкретного сокета + + // После удаления игрока из GameInstance, проверяем состояние игры и GameManager + if (game.playerCount === 0) { + // Если в игре больше нет игроков, удаляем ее из GameManager + console.log(`[GameManager] Игра ${gameId} пуста после дисконнекта ${socketId} (идентификатор ${identifier}). Удаляем.`); + // Используем централизованную функцию очистки + this._cleanupGame(gameId, 'player_count_zero_on_disconnect'); + + } else if (game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) { + // Если игра PvP, остался 1 игрок, и она еще не окончена (из-за дисконнекта), + // возвращаем ее в список ожидающих. + console.log(`[GameManager] Игра ${gameId} (PvP) теперь с 1 игроком после дисконнекта ${socketId} (идентификатор ${identifier}). Возвращаем в список ожидания.`); + if (!this.pendingPvPGames.includes(gameId)) { + this.pendingPvPGames.push(gameId); + } + // Удаляем ссылку на игру только для отключившегося идентификатора + delete this.userIdentifierToGameId[identifier]; + + // ownerIdentifier игры (если был userId) останется тем же, даже если отключился владелец. + // Это OK, ownerIdentifier используется для _removePreviousPendingGames. + + this.broadcastAvailablePvPGames(); // Обновляем список у всех + } else if (game.gameState?.isGameOver) { + // Если игра была окончена (например, дисконнект приводил к gameOver), + // просто удаляем ссылку на игру для отключившегося идентификатора. + console.log(`[GameManager] Игрок ${identifier} отключился из завершенной игры ${gameId}. Удаляем ссылку.`); + delete this.userIdentifierToGameId[identifier]; + } else { + // Игра не пуста и не вернулась в ожидание (например, AI игра, где остался игрок, + // или PvP игра с 2 игроками, где один отключился, а второй остался) + // Ссылка userIdentifierToGameId[identifier] для отключившегося игрока должна быть удалена. + console.log(`[GameManager] Игрок ${identifier} отключился из активной игры ${gameId} (mode: ${game.mode}, players: ${game.playerCount}). Удаляем ссылку.`); + delete this.userIdentifierToGameId[identifier]; } - this.broadcastAvailablePvPGames(); - } else if (game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) { - if (!this.pendingPvPGames.includes(gameId)) { - this.pendingPvPGames.push(gameId); - } - const remainingPlayerSocketId = Object.keys(game.players)[0]; - const remainingPlayerSocket = game.players[remainingPlayerSocketId]?.socket; - const remainingUserId = remainingPlayerSocket?.userData?.userId; - const newIdentifier = remainingUserId || remainingPlayerSocketId; - - game.ownerUserId = remainingUserId; - this.userToPendingGame[newIdentifier] = gameId; - - if (identifier !== newIdentifier && this.userToPendingGame[identifier] === gameId) { - delete this.userToPendingGame[identifier]; - } - console.log(`[GameManager] Игра ${gameId} возвращена в список ожидания PvP. Новый владелец: ${newIdentifier}`); - this.broadcastAvailablePvPGames(); + } else { + // Игра найдена, но игрока с этим идентификатором или сокетом в game.players нет. + // Это может означать, что сокет отключился, но запись игрока была удалена раньше, + // или identifier некорректен. + console.warn(`[GameManager] Игрок с идентификатором ${identifier} (сокет: ${socketId}) не найден в game.players для игры ${gameId}.`); + // Удаляем ссылку на игру для этого идентификатора, если она есть. + delete this.userIdentifierToGameId[identifier]; + // Проверяем, возможно, этот сокет был в другой игре по старой ссылке socketToGame (удалено), + // или это просто отключившийся сокет без активной игры. } } else { - const pendingGameIdToRemove = this.userToPendingGame[identifier]; - if (pendingGameIdToRemove && this.games[pendingGameIdToRemove] && this.games[pendingGameIdToRemove].playerCount === 1) { - console.log(`[GameManager] Игрок ${socketId} (identifier: ${identifier}) отключился, удаляем его ожидающую игру ${pendingGameIdToRemove}`); - delete this.games[pendingGameIdToRemove]; - const idx = this.pendingPvPGames.indexOf(pendingGameIdToRemove); - if (idx > -1) this.pendingPvPGames.splice(idx, 1); - delete this.userToPendingGame[identifier]; - this.broadcastAvailablePvPGames(); - } + // Если игра не найдена по userIdentifierToGameId[identifier] + console.log(`[GameManager] Отключился сокет ${socketId} (идентификатор ${identifier}). Игровая сессия по этому идентификатору не найдена.`); + // Убеждаемся, что ссылка userIdentifierToGameId[identifier] удалена + delete this.userIdentifierToGameId[identifier]; } - delete this.socketToGame[socketId]; } + + /** + * Централизованная функция для очистки игры после ее завершения. + * Удаляет экземпляр игры и все связанные с ней ссылки. + * Вызывается из GameInstance при gameOver (по HP или дисконнекту). + * @param {string} gameId - ID завершенной игры. + * @param {string} reason - Причина завершения (для логирования). + * @returns {boolean} true, если игра найдена и очищена, иначе false. + */ + _cleanupGame(gameId, reason = 'unknown_reason') { // <-- НОВЫЙ ПРИВАТНЫЙ МЕТОД + const game = this.games[gameId]; + if (!game) { + console.warn(`[GameManager] _cleanupGame called for unknown game ID: ${gameId}`); + return false; + } + + console.log(`[GameManager] Cleaning up game ${gameId} (Mode: ${game.mode}, Reason: ${reason})...`); + + // Удаляем ссылку userIdentifierToGameId для всех игроков, которые были в этой игре + // Перебираем players в GameInstance, чтобы получить идентификаторы + Object.values(game.players).forEach(playerInfo => { + if (playerInfo && playerInfo.identifier && this.userIdentifierToGameId[playerInfo.identifier] === gameId) { + delete this.userIdentifierToGameId[playerInfo.identifier]; + console.log(`[GameManager] Removed userIdentifierToGameId for ${playerInfo.identifier}.`); + } else if (playerInfo && playerInfo.identifier) { + console.warn(`[GameManager] User ${playerInfo.identifier} in game ${gameId} has incorrect userIdentifierToGameId reference.`); + // Если ссылка некорректна, ничего не удаляем. + } + }); + + // Удаляем ID игры из списка ожидающих, если она там была + const pendingIndex = this.pendingPvPGames.indexOf(gameId); + if (pendingIndex > -1) { + this.pendingPvPGames.splice(pendingIndex, 1); + console.log(`[GameManager] Removed game ${gameId} from pendingPvPGames.`); + } + + // Удаляем сам экземпляр игры + delete this.games[gameId]; + console.log(`[GameManager] Deleted GameInstance for game ${gameId}.`); + + // Оповещаем клиентов об обновленном списке игр (может понадобиться, если удалена ожидающая игра) + // Или если активная игра была удалена, и игроки вернутся в лобби. + this.broadcastAvailablePvPGames(); + + return true; + } + + + /** + * Формирует список доступных для присоединения PvP игр для клиента. + * @returns {Array} Массив объектов с информацией об играх. + */ getAvailablePvPGamesListForClient() { return this.pendingPvPGames .map(gameId => { const game = this.games[gameId]; - if (game && game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) { + // Проверяем, что игра существует, это PvP, в ней 1 игрок, и она не окончена + // gameState.isGameOver проверяется, чтобы исключить игры, которые могли завершиться сразу (очень маловероятно) + if (game && game.mode === 'pvp' && game.playerCount === 1 && game.gameState && !game.gameState.isGameOver) { let firstPlayerUsername = 'Игрок'; let firstPlayerCharacterName = ''; - if (game.players && Object.keys(game.players).length > 0) { - const firstPlayerSocketId = Object.keys(game.players)[0]; - const firstPlayerInfo = game.players[firstPlayerSocketId]; + // Находим информацию о первом игроке (он всегда в слоте GAME_CONFIG.PLAYER_ID в ожидающей игре) + const firstPlayerInfo = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - if (firstPlayerInfo) { - if (firstPlayerInfo.socket?.userData?.username) { - firstPlayerUsername = firstPlayerInfo.socket.userData.username; - } - - const charKey = firstPlayerInfo.chosenCharacterKey; - if (charKey) { - let charBaseStats; - if (charKey === 'elena') { - charBaseStats = gameData.playerBaseStats; - } else if (charKey === 'almagest') { - charBaseStats = gameData.almagestBaseStats; - } - // Баларда не должно быть в pending PvP как создателя - - if (charBaseStats && charBaseStats.name) { - firstPlayerCharacterName = charBaseStats.name; - } else { - console.warn(`[GameManager] getAvailablePvPGamesList: Не удалось найти имя для charKey '${charKey}' в gameData.`); - firstPlayerCharacterName = charKey; // В крайнем случае ключ - } - } else { - console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo.chosenCharacterKey отсутствует для игры ${gameId}`); - } + if (firstPlayerInfo) { + // Получаем имя пользователя из userData, если залогинен + if (firstPlayerInfo.socket?.userData?.username) { + firstPlayerUsername = firstPlayerInfo.socket.userData.username; + } else { + // Если нет userData.username, используем часть identifier + firstPlayerUsername = `User#${String(firstPlayerInfo.identifier).substring(0,6)}`; // Приводим identifier к строке } + + // Получаем имя персонажа из chosenCharacterKey + const charKey = firstPlayerInfo.chosenCharacterKey; + if (charKey) { + // Используем _getCharacterBaseData напрямую, т.к. gameData доступен + const charBaseStats = this._getCharacterBaseData(charKey); + if (charBaseStats && charBaseStats.name) { + firstPlayerCharacterName = charBaseStats.name; + } else { + //console.warn(`[GameManager] getAvailablePvPGamesList: Не удалось найти имя для charKey '${charKey}' в gameData.`); + firstPlayerCharacterName = charKey; // В крайнем случае используем ключ + } + } else { + //console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo.chosenCharacterKey отсутствует для игры ${gameId}.`); + } + } else { + console.warn(`[GameManager] getAvailablePvPGamesList: firstPlayerInfo (Player 1) не найдена для ожидающей игры ${gameId}.`); + firstPlayerUsername = 'Неизвестный игрок'; // Если даже игрока не нашли в players } + // Формируем строку статуса для отображения в списке let statusString = `Ожидает 1 игрока (Создал: ${firstPlayerUsername}`; if (firstPlayerCharacterName) { statusString += ` за ${firstPlayerCharacterName}`; @@ -272,40 +522,271 @@ class GameManager { statusString += `)`; return { - id: gameId, + id: gameId, // Отправляем полный ID, но в списке UI показываем обрезанный status: statusString }; } - return null; + // Если игра не соответствует критериям ожидающей (например, пуста, заполнена, окончена), не включаем ее + if (game && !this.pendingPvPGames.includes(gameId)) { + // Если игра есть, но не в pendingPvPGames, она не должна тут обрабатываться. + } else if (game && game.playerCount === 1 && (game.gameState?.isGameOver || !game.gameState)) { + // Игра с 1 игроком, но окончена или не инициализирована - не показывать + } else if (game && game.playerCount === 2) { + // Игра заполнена - не показывать + } else if (game && game.playerCount === 0) { + // Игра пуста - ее надо было удалить при дисконнекте последнего игрока. + // Возможно, тут нужна очистка таких "потерянных" игр. + console.warn(`[GameManager] getAvailablePvPGamesList: Найдена пустая игра ${gameId} в games. Удаляем.`); + delete this.games[gameId]; // Удаляем потерянную игру + // Очистка из pendingPvPGames не нужна, т.к. она удаляется при playerCount === 0 + } + + return null; // Исключаем игры, не соответствующие критериям или удаленные }) - .filter(info => info !== null); + .filter(info => info !== null); // Удаляем null из результатов map } + /** + * Отправляет обновленный список доступных PvP игр всем подключенным клиентам. + */ broadcastAvailablePvPGames() { - this.io.emit('availablePvPGamesList', this.getAvailablePvPGamesListForClient()); + const availableGames = this.getAvailablePvPGamesListForClient(); + this.io.emit('availablePvPGamesList', availableGames); + console.log(`[GameManager] Обновлен список доступных PvP игр. Всего: ${availableGames.length}`); } + /** + * Получает список активных игр для отладки на сервере. + * @returns {Array} Список объектов с краткой информацией об играх. + */ getActiveGamesList() { // Для отладки на сервере return Object.values(this.games).map(game => { - let playerSlotChar = game.gameState?.player?.name || (game.playerCharacterKey ? gameData[game.playerCharacterKey === 'elena' ? 'playerBaseStats' : (game.playerCharacterKey === 'almagest' ? 'almagestBaseStats' : null)]?.name : 'N/A'); - let opponentSlotChar = game.gameState?.opponent?.name || (game.opponentCharacterKey ? gameData[game.opponentCharacterKey === 'elena' ? 'playerBaseStats' : (game.opponentCharacterKey === 'almagest' ? 'almagestBaseStats' : (game.opponentCharacterKey === 'balard' ? 'opponentBaseStats' : null))]?.name : 'N/A'); + // Получаем имена персонажей из gameState, если игра инициализирована, иначе из chosenCharacterKey/default + let playerSlotCharName = game.gameState?.player?.name || (game.playerCharacterKey ? this._getCharacterBaseData(game.playerCharacterKey)?.name : 'N/A (ожидание)'); + let opponentSlotCharName = game.gameState?.opponent?.name || (game.opponentCharacterKey ? this._getCharacterBaseData(game.opponentCharacterKey)?.name : 'N/A (ожидание)'); - if (game.mode === 'pvp' && game.playerCount === 1 && !game.opponentCharacterKey && game.gameState && !game.gameState.isGameOver) { - opponentSlotChar = 'Ожидание...'; - } + // Проверяем наличие игроков в слотах, чтобы уточнить статус + const playerInSlot1 = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); + const playerInSlot2 = Object.values(game.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); + + if (!playerInSlot1) playerSlotCharName = 'Пусто'; + if (!playerInSlot2 && game.mode === 'pvp') opponentSlotCharName = 'Ожидание...'; // В PvP слоты могут быть пустыми + if (!playerInSlot2 && game.mode === 'ai' && game.aiOpponent) opponentSlotCharName = 'Балард (AI)'; // В AI слоте оппонента всегда AI return { - id: game.id.substring(0,8), + id: game.id.substring(0,8), // Обрезанный ID для удобства mode: game.mode, playerCount: game.playerCount, - isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A', - playerSlot: playerSlotChar, - opponentSlot: opponentSlotChar, - ownerUserId: game.ownerUserId || 'N/A', - pending: this.pendingPvPGames.includes(game.id) + isGameOver: game.gameState ? game.gameState.isGameOver : 'N/A (Не инициализирована)', + playerSlot: playerSlotCharName, + opponentSlot: opponentSlotCharName, + ownerIdentifier: game.ownerIdentifier || 'N/A', + pending: this.pendingPvPGames.includes(game.id), + turn: game.gameState ? `Ход ${game.gameState.turnNumber}, ${game.gameState.isPlayerTurn ? (playerInSlot1?.identifier || 'Player Slot') : (playerInSlot2?.identifier || 'Opponent Slot')}` : 'N/A' }; }); } + + /** + * Обрабатывает запрос клиента на gameState (например, при переподключении). + * Находит игру пользователя по его идентификатору и отправляет ему актуальное состояние. + * Также обновляет ссылку на сокет в GameInstance. + * @param {object} socket - Сокет клиента, запросившего состояние. + * @param {string|number} identifier - ID пользователя (userId или socketId). + */ + handleRequestGameState(socket, identifier) { // Принимаем socket и identifier + // Ищем игру пользователя по его идентификатору + const gameId = this.userIdentifierToGameId[identifier]; + let game = null; + + if (gameId) { + game = this.games[gameId]; + } + + // Если игра найдена и она существует, и в ней есть игрок с этим идентификатором + if (game && game.players) { + const playerInfo = Object.values(game.players).find(p => p.identifier === identifier); + + if (playerInfo) { + // Проверяем, если игра окончена, не восстанавливаем состояние, а информируем + if (game.gameState?.isGameOver) { + console.log(`[GameManager] Reconnected user ${identifier} to game ${gameId} which is already over. Sending gameNotFound.`); + // Удаляем ссылку на оконченную игру для этого пользователя + delete this.userIdentifierToGameId[identifier]; + // Отправляем gameNotFound, чтобы клиент вернулся в меню + socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' }); + return; // Прекращаем обработку + } + + + console.log(`[GameManager] Found game ${gameId} for identifier ${identifier} (role ${playerInfo.id}). Reconnecting socket ${socket.id}.`); + + // --- Обновляем GameInstance: заменяем старый сокет на новый для этого игрока --- + // Удаляем старую запись игрока по старому socket.id, если она есть и отличается + const oldSocketId = playerInfo.socket?.id; + if (oldSocketId && oldSocketId !== socket.id && game.players[oldSocketId]) { + console.log(`[GameManager] Updating socket ID for player ${identifier} from ${oldSocketId} to ${socket.id} in game ${gameId}.`); + delete game.players[oldSocketId]; // Удаляем запись по старому socketId + // playerCount не уменьшаем/увеличиваем, т.к. это тот же игрок, просто сменил сокет + // Удаляем ссылку на старый сокет по роли + if (game.playerSockets[playerInfo.id]?.id === oldSocketId) { + delete game.playerSockets[playerInfo.id]; + } + } + + // Добавляем или обновляем запись для нового сокета, связывая его с существующим идентификатором игрока + game.players[socket.id] = playerInfo; // Переиспользуем существующий объект playerInfo + game.players[socket.id].socket = socket; // Обновляем объект сокета + // Ensure the identifier and role are correct on the new socket entry + game.players[socket.id].identifier = identifier; // Make sure identifier is set (уже должно быть, но на всякий случай) + // playerInfo.id should already be correct (player/opponent role) + + game.playerSockets[playerInfo.id] = socket; // Обновляем ссылку на сокет по роли + + // Убеждаемся, что новый socket.id теперь связан с этой игрой в GameManager - НЕ НУЖНО, socketToGame удален + // this.socketToGame[socket.id] = game.id; + + + // Присоединяем новый сокет к комнате Socket.IO + socket.join(game.id); + // --- КОНЕЦ Обновления сокета --- + + + // Получаем данные персонажей с точки зрения этого клиента + // playerInfo.chosenCharacterKey - это персонаж этого клиента + const playerCharDataForClient = this._getCharacterData(playerInfo.chosenCharacterKey); + // Определяем ключ персонажа оппонента с точки зрения этого клиента + const opponentActualSlotId = playerInfo.id === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; + const opponentCharacterKeyForClient = game.gameState?.[opponentActualSlotId]?.characterKey || null; // Берем из gameState, т.к. там актуальное состояние слотов + // Если оппонент еще не определен в gameState (PvP ожидание), используем playerCharacterKey/opponentCharacterKey из gameInstance + // ВАЖНО: при переподключении к *активной* игре, gameState.opponent.characterKey ДОЛЖЕН БЫТЬ определен. + // Если он null, это может быть PvP ожидание или некорректное состояние. + if (!opponentCharacterKeyForClient) { + // Попробуем найти ключ из GameInstance properties (они устанавливаются при инициализации) + const opponentSlotKeyInInstance = playerInfo.id === GAME_CONFIG.PLAYER_ID ? game.playerCharacterKey : game.opponentCharacterKey; // ИСПРАВЛЕНО: Логика получения ключа оппонента + opponentCharacterKeyForClient = opponentSlotKeyInInstance; + // Если даже из GameInstance properties ключ null, это точно PvP ожидание или критическая ошибка + } + const opponentCharDataForClient = this._getCharacterData(opponentCharacterKeyForClient); // Данные оппонента с т.з. клиента + + + if (playerCharDataForClient && opponentCharDataForClient && game.gameState) { + // Проверяем, готово ли gameState к игре (определены оба бойца) + const isGameReadyForPlay = (game.mode === 'ai' && game.playerCount === 1) || (game.mode === 'pvp' && game.playerCount === 2); + const isOpponentDefinedInState = game.gameState.opponent?.characterKey && game.gameState.opponent?.name !== 'Ожидание игрока...'; + + + socket.emit('gameState', { + gameId: game.id, + yourPlayerId: playerInfo.id, // ID слота этого клиента в игре + gameState: game.gameState, + playerBaseStats: playerCharDataForClient.baseStats, // Статы "моего" персонажа для клиента + opponentBaseStats: opponentCharDataForClient.baseStats, // Статы "моего" оппонента для клиента + playerAbilities: playerCharDataForClient.abilities, // Абилки "моего" персонажа для клиента + opponentAbilities: opponentCharDataForClient.abilities, // Абилки "моего" оппонента для клиента + log: game.consumeLogBuffer(), // Отправляем текущий лог и очищаем буфер игры + clientConfig: { ...GAME_CONFIG } // Отправляем копию конфига + }); + console.log(`[GameManager] Sent gameState to socket ${socket.id} (identifier: ${identifier}) for game ${game.id}.`); + + // Логика старта игры при переподключении (если она еще не началась) + // Эта логика должна быть только для случая, когда переподключившийся игрок ЗАВЕРШАЕТ состав игры + // (например, второй игрок в PvP переподключился к ожидающей игре). + // Если игра уже началась, startGame не должен вызываться повторно. + // Проверяем: игра не окончена, готова к игре (2 игрока или AI), и состояние оппонента НЕ БЫЛО определено до этого запроса (признак не полностью стартовавшей игры) + if (!game.gameState.isGameOver && isGameReadyForPlay && !isOpponentDefinedInState) { + console.log(`[GameManager] Game ${game.id} found ready but not fully started on reconnect (Opponent state missing). Initializing/Starting.`); + // Инициализируем состояние игры. initializeGame вернет true, если оба бойца определены. + const isInitialized = game.initializeGame(); // Переинициализируем state полностью с обоими персонажами + if (isInitialized) { // Проверяем, успешно ли инициализировалось состояние + game.startGame(); // Запускаем игру (это отправит gameStarted всем, включая этого клиента) + } else { + console.error(`[GameManager] Failed to initialize game ${game.id} on reconnect. Cannot start.`); + // Дополнительная обработка ошибки, возможно, уведомить игроков + this.io.to(game.id).emit('gameError', { message: 'Ошибка сервера при старте игры после переподключения. Не удалось инициализировать игру.' }); + // Если инициализация провалилась, игра в некорректном состоянии, нужно ее удалить + this._cleanupGame(gameId, 'reconnect_initialization_failed'); + } + } + // Если игра уже активно идет (не окончена, не ожидание) и состояние оппонента БЫЛО определено, + // то startGame не вызывается повторно. Клиент получит gameStateUpdate от обычного хода игры. + // Если игра PvP ожидающая (1 игрок), startGame не вызывается, isGameReadyForPlay будет false. + else if (!isGameReadyForPlay) { + console.log(`[GameManager] Reconnected user ${identifier} to pending game ${gameId}. Sending gameState and waiting status.`); + // Если это ожидающая игра, убедимся, что клиент получает статус ожидания + socket.emit('waitingForOpponent'); + } else if (game.gameState.isGameOver) { + console.log(`[GameManager] Reconnected to game ${gameId} which is already over. Sending gameNotFound.`); + // Если игра окончена, client.js должен по gameState.isGameOver показать модалку. + // Но чтобы гарантировать возврат в меню при последующих запросах, лучше отправить gameNotFound. + // Удаляем ссылку на оконченную игру для этого пользователя + delete this.userIdentifierToGameId[identifier]; + // Отправляем gameNotFound + socket.emit('gameNotFound', { message: 'Ваша предыдущая игровая сессия уже завершена.' }); + } else { + // Переподключение к активной игре, которая уже полностью стартовала. + console.log(`[GameManager] Reconnected user ${identifier} to active game ${gameId}. gameState sent.`); + } + + + } else { + console.error(`[GameManager] Failed to send gameState to ${socket.id} (identifier ${identifier}) for game ${gameId}: missing character data or gameState.`); + socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' }); + // Если данные для отправки некорректны, игра в некорректном состоянии, нужно ее удалить + this._cleanupGame(gameId, 'reconnect_send_failed'); + socket.emit('gameNotFound', { message: 'Ваша игровая сессия в некорректном состоянии и была завершена.' }); + } + + } else { + // Игра найдена по идентификатору пользователя, но игрока с этим идентификатором нет в players этой игры. + // Это очень странная ситуация, возможно, state userIdentifierToGameId некорректен. + console.warn(`[GameManager] Found game ${gameId} by identifier ${identifier}, but player with this identifier not found in game.players.`); + // Удаляем некорректную ссылку и отправляем gameNotFound + delete this.userIdentifierToGameId[identifier]; + socket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена. Возможно, идентификатор пользователя некорректен.' }); + } + } else { + // Игра не найдена по userIdentifierToGameId[identifier] + console.log(`[GameManager] No active or pending game found for identifier ${identifier}.`); + socket.emit('gameNotFound', { message: 'Игровая сессия не найдена.' }); // Уведомляем клиента, что игра не найдена + } + } + + // --- Вспомогательные функции для получения данных персонажа из data.js --- + // Скопировано из gameInstance.js, т.к. gameManager тоже использует gameData напрямую + /** + * Получает базовые статы и список способностей для персонажа по ключу. + * Эти функции предназначены для использования ВНУТРИ GameManager или GameInstance. + * @param {string} key - Ключ персонажа ('elena', 'balard', 'almagest'). + * @returns {{baseStats: object, abilities: array}|null} Объект с базовыми статами и способностями, или null. + */ + _getCharacterData(key) { + if (!key) { console.warn("GameManager::_getCharacterData called with null/undefined key."); return null; } + switch (key) { + case 'elena': return { baseStats: gameData.playerBaseStats, abilities: gameData.playerAbilities }; + case 'balard': return { baseStats: gameData.opponentBaseStats, abilities: gameData.opponentAbilities }; // Балард использует opponentAbilities из data.js + case 'almagest': return { baseStats: gameData.almagestBaseStats, abilities: gameData.almagestAbilities }; // Альмагест использует almagestAbilities из data.js + default: console.error(`GameManager::_getCharacterData: Unknown character key "${key}"`); return null; + } + } + /** + * Получает только базовые статы для персонажа по ключу. + * @param {string} key - Ключ персонажа. + * @returns {object|null} Базовые статы или null. + */ + _getCharacterBaseData(key) { + const charData = this._getCharacterData(key); + return charData ? charData.baseStats : null; + } + /** + * Получает только список способностей для персонажа по ключу. + * @param {string} key - Ключ персонажа. + * @returns {array|null} Список способностей или null. + */ + _getCharacterAbilities(key) { + const charData = this._getCharacterData(key); + return charData ? charData.abilities : null; + } } module.exports = GameManager; \ No newline at end of file