From ec459f65d1cf17951db5e83901226bc218b8b691 Mon Sep 17 00:00:00 2001 From: PsiMagistr Date: Mon, 26 May 2025 20:07:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D0=BC=D0=B5=D1=88=D0=B5=D0=BA=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D1=88=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BD=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D1=83.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/bc.js | 66 +++++---- server/data/taunts.js | 2 +- server/game/logic/combatLogic.js | 232 ++++++++++++++++++++----------- 3 files changed, 189 insertions(+), 111 deletions(-) diff --git a/server/bc.js b/server/bc.js index 85398dd..9248825 100644 --- a/server/bc.js +++ b/server/bc.js @@ -11,7 +11,7 @@ const cors = require('cors'); const authService = require('./auth/authService'); const GameManager = require('./game/GameManager'); -const db = require('./core/db'); +const db = require('./core/db'); // Используется для auth, не для игр в этом варианте const GAME_CONFIG = require('./core/config'); const app = express(); @@ -84,7 +84,7 @@ if (!socketCorsOrigin && process.env.NODE_ENV !== 'development' && process.env.N } const io = new Server(server, { - path: '/socket.io/', // Убедитесь, что это соответствует клиенту и прокси (stripPrefix: false для /socket.io) + path: '/socket.io/', cors: { origin: socketCorsOrigin, methods: ["GET", "POST"], @@ -94,7 +94,7 @@ const io = new Server(server, { console.log(`[BC.JS CONFIG] Socket.IO server configured with path: ${io.path()} and effective CORS origin: ${io.opts.cors.origin === '*' ? "'*'" : io.opts.cors.origin || 'NOT SET'}`); const gameManager = new GameManager(io); -const loggedInUsersBySocketId = {}; // Хранилище для данных пользователя по ID сокета +const loggedInUsersBySocketId = {}; // --- MIDDLEWARE АУТЕНТИФИКАЦИИ SOCKET.IO --- io.use(async (socket, next) => { @@ -108,17 +108,16 @@ io.use(async (socket, next) => { if (token) { try { const decoded = jwt.verify(token, process.env.JWT_SECRET); - socket.userData = { userId: decoded.userId, username: decoded.username }; // Прикрепляем данные к объекту сокета + socket.userData = { userId: decoded.userId, username: decoded.username }; console.log(`[BC Socket.IO Middleware] Socket ${socket.id} authenticated for user ${decoded.username} (ID: ${decoded.userId}).`); return next(); } catch (err) { console.warn(`[BC Socket.IO Middleware] Socket ${socket.id} auth failed: Invalid token. Error: ${err.message}. Proceeding as unauthenticated.`); - // Не прерываем соединение, но userData не будет установлен } } else { console.log(`[BC Socket.IO Middleware] Socket ${socket.id} has no token. Proceeding as unauthenticated.`); } - next(); // Разрешаем подключение даже неаутентифицированным + next(); }); // --- ОБРАБОТЧИКИ СОБЫТИЙ SOCKET.IO --- @@ -129,7 +128,19 @@ io.on('connection', (socket) => { if (socket.userData && socket.userData.userId) { console.log(`[BC Socket.IO Connection] Authenticated user ${socket.userData.username} (ID: ${socket.userData.userId}) connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}`); - loggedInUsersBySocketId[socket.id] = socket.userData; // Сохраняем данные пользователя, связанные с этим сокетом + loggedInUsersBySocketId[socket.id] = socket.userData; + + // --- НАЧАЛО ИЗМЕНЕНИЯ --- + // Отправляем текущий список доступных PvP игр этому конкретному сокету + // после успешной аутентификации. + if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') { + console.log(`[BC Socket.IO Connection] Sending initial available PvP games list to authenticated user ${socket.userData.username} (Socket: ${socket.id})`); + const availableGames = gameManager.getAvailablePvPGamesListForClient(); + socket.emit('availablePvPGamesList', availableGames); + } else { + console.error("[BC Socket.IO Connection] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found for sending initial list!"); + } + // --- КОНЕЦ ИЗМЕНЕНИЯ --- if (gameManager && typeof gameManager.handleRequestGameState === 'function') { gameManager.handleRequestGameState(socket, socket.userData.userId); @@ -138,19 +149,28 @@ io.on('connection', (socket) => { } } else { console.log(`[BC Socket.IO Connection] Unauthenticated user connected. Socket: ${socket.id}, IP: ${clientIp}, Origin: ${originHeader}, Path: ${socketPath}.`); + // --- НАЧАЛО ИЗМЕНЕНИЯ (опционально, если неаутентифицированные тоже видят список) --- + // Если неаутентифицированные пользователи тоже должны видеть список игр + /* + if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') { + console.log(`[BC Socket.IO Connection] Sending initial available PvP games list to unauthenticated socket ${socket.id}`); + const availableGames = gameManager.getAvailablePvPGamesListForClient(); + socket.emit('availablePvPGamesList', availableGames); + } else { + console.error("[BC Socket.IO Connection] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found for sending initial list to unauth user!"); + } + */ + // --- КОНЕЦ ИЗМЕНЕНИЯ --- } - socket.on('logout', () => { // Инициируется клиентом ПЕРЕД разрывом соединения и удалением токена + socket.on('logout', () => { const username = socket.userData?.username || 'UnknownUserOnLogout'; const userId = socket.userData?.userId; console.log(`[BC Socket.IO 'logout' event] User: ${username} (ID: ${userId || 'N/A'}, Socket: ${socket.id}).`); - // Логика GameManager.handleDisconnect будет вызвана при фактическом 'disconnect' событии. - // Здесь мы просто очищаем данные, связанные с этим сокетом на сервере, - // так как клиент собирается разорвать соединение или переподключиться без токена. if (loggedInUsersBySocketId[socket.id]) { delete loggedInUsersBySocketId[socket.id]; } - socket.userData = null; // Очищаем данные на самом объекте сокета + socket.userData = null; console.log(`[BC Socket.IO 'logout' event] Session data for socket ${socket.id} cleared on server.`); }); @@ -171,7 +191,6 @@ io.on('connection', (socket) => { } }); - // --- НАЧАЛО ИЗМЕНЕНИЯ: ОБРАБОТЧИК ДЛЯ ВЫХОДА ИЗ AI-ИГРЫ --- socket.on('leaveAiGame', () => { if (!socket.userData?.userId) { console.warn(`[BC Socket.IO 'leaveAiGame'] Denied for unauthenticated socket ${socket.id}.`); @@ -184,14 +203,11 @@ io.on('connection', (socket) => { if (gameManager && typeof gameManager.handleLeaveAiGame === 'function') { gameManager.handleLeaveAiGame(identifier); - // Ответ клиенту не требуется, т.к. он и так выходит и переходит на другой экран. - // GameManager._cleanupGame будет вызван изнутри handleLeaveAiGame через GameInstance. } else { console.error("[BC Socket.IO 'leaveAiGame'] CRITICAL: gameManager or handleLeaveAiGame method not found!"); socket.emit('gameError', { message: 'Ошибка сервера при выходе из AI игры.' }); } }); - // --- КОНЕЦ ИЗМЕНЕНИЯ --- socket.on('createGame', (data) => { if (!socket.userData?.userId) { @@ -207,7 +223,6 @@ io.on('connection', (socket) => { }); socket.on('joinGame', (data) => { - // ... (код без изменений) if (!socket.userData?.userId) { console.warn(`[BC Socket.IO 'joinGame'] Denied for unauthenticated socket ${socket.id}.`); socket.emit('gameError', { message: 'Необходимо войти для присоединения к PvP игре.' }); @@ -220,7 +235,6 @@ io.on('connection', (socket) => { }); socket.on('findRandomGame', (data) => { - // ... (код без изменений) if (!socket.userData?.userId) { console.warn(`[BC Socket.IO 'findRandomGame'] Denied for unauthenticated socket ${socket.id}.`); socket.emit('gameError', { message: 'Необходимо войти для поиска случайной PvP игры.' }); @@ -233,14 +247,17 @@ io.on('connection', (socket) => { }); socket.on('requestPvPGameList', () => { - // ... (код без изменений) console.log(`[BC Socket.IO 'requestPvPGameList'] Request from socket ${socket.id} (User: ${socket.userData?.username || 'Unauth'}).`); - const availableGames = gameManager.getAvailablePvPGamesListForClient(); - socket.emit('availablePvPGamesList', availableGames); + if (gameManager && typeof gameManager.getAvailablePvPGamesListForClient === 'function') { + const availableGames = gameManager.getAvailablePvPGamesListForClient(); + socket.emit('availablePvPGamesList', availableGames); + } else { + console.error("[BC Socket.IO 'requestPvPGameList'] CRITICAL: gameManager or getAvailablePvPGamesListForClient not found!"); + socket.emit('availablePvPGamesList', []); // Отправляем пустой список в случае ошибки + } }); socket.on('requestGameState', () => { - // ... (код без изменений) if (!socket.userData?.userId) { console.warn(`[BC Socket.IO 'requestGameState'] Denied for unauthenticated socket ${socket.id}.`); socket.emit('gameNotFound', { message: 'Необходимо войти для восстановления игры.' }); @@ -252,7 +269,6 @@ io.on('connection', (socket) => { }); socket.on('playerAction', (actionData) => { - // ... (код без изменений) if (!socket.userData?.userId) { console.warn(`[BC Socket.IO 'playerAction'] Denied for unauthenticated socket ${socket.id}. Action: ${actionData?.actionType}`); socket.emit('gameError', { message: 'Действие не разрешено: пользователь не аутентифицирован.' }); @@ -264,7 +280,7 @@ io.on('connection', (socket) => { }); socket.on('disconnect', (reason) => { - const identifier = socket.userData?.userId; // Берем из userData, если был аутентифицирован + const identifier = socket.userData?.userId; const username = socket.userData?.username || loggedInUsersBySocketId[socket.id]?.username || 'UnauthenticatedOrUnknown'; console.log(`[BC Socket.IO Disconnect] User ${username} (ID: ${identifier || 'N/A'}, Socket: ${socket.id}) disconnected. Reason: ${reason}.`); @@ -274,7 +290,6 @@ io.on('connection', (socket) => { if (loggedInUsersBySocketId[socket.id]) { delete loggedInUsersBySocketId[socket.id]; } - // socket.userData автоматически очистится при уничтожении объекта сокета }); }); @@ -308,6 +323,5 @@ process.on('unhandledRejection', (reason, promise) => { process.on('uncaughtException', (err) => { console.error('[BC Server FATAL UncaughtException] Error:', err); - // В продакшене PM2 или другой менеджер процессов должен перезапустить приложение process.exit(1); }); \ No newline at end of file diff --git a/server/data/taunts.js b/server/data/taunts.js index d0f5fce..16dea67 100644 --- a/server/data/taunts.js +++ b/server/data/taunts.js @@ -10,7 +10,7 @@ const tauntSystem = { balard: { // Против Баларда (AI) // Триггер: Елена использует СВОЮ способность selfCastAbility: { - [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине." ], + [GAME_CONFIG.ABILITY_ID_HEAL]: [ "Свет лечит, Балард. Но не искаженную завистью искру.", "Я черпаю силы в Истине."], [GAME_CONFIG.ABILITY_ID_FIREBALL]: [ "Прими очищающее пламя Света!", "Пусть твой мрак сгорит!" ], [GAME_CONFIG.ABILITY_ID_NATURE_STRENGTH]: [ "Сама земля отвергает тебя, я черпаю её силу!", "Гармония природы со мной." ], [GAME_CONFIG.ABILITY_ID_DEFENSE_AURA]: [ "Порядок восторжествует над твоим хаосом.", "Моя вера - моя защита." ], diff --git a/server/game/logic/combatLogic.js b/server/game/logic/combatLogic.js index c2640aa..110db45 100644 --- a/server/game/logic/combatLogic.js +++ b/server/game/logic/combatLogic.js @@ -1,6 +1,9 @@ // /server/game/logic/combatLogic.js // GAME_CONFIG и dataUtils будут передаваться в функции как параметры. +// effectsLogic может потребоваться для импорта, если updateBlockingStatus используется здесь напрямую, +// но в вашем GameInstance.js он вызывается отдельно. +// const effectsLogic = require('./effectsLogic'); // Если нужно /** * Обрабатывает базовую атаку одного бойца по другому. @@ -34,7 +37,6 @@ function performAttack( let attackBonusesLog = []; // Для сбора информации о бонусах к атаке // --- ПРОВЕРКА И ПРИМЕНЕНИЕ БОНУСА ОТ ОТЛОЖЕННОГО БАФФА АТАКИ --- - // Ищем активный бафф, который должен сработать ПРИ атаке const delayedAttackBuff = attackerState.activeEffects.find(eff => eff.isDelayed && (eff.id === configToUse.ABILITY_ID_NATURE_STRENGTH || eff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) && @@ -45,28 +47,25 @@ function performAttack( if (delayedAttackBuff) { console.log(`[CombatLogic performAttack] Found active delayed buff: ${delayedAttackBuff.name} for ${attackerState.name}`); - // 1. Применяем бонус к урону (если он есть в конфиге/данных эффекта) let damageBonus = 0; - if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH) { - // Предположим, что Сила Природы НЕ дает прямого бонуса к урону атаки, а только реген маны. - // Если бы давала, то: damageBonus = configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS || 0; - } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) { - // Аналогично для Альмагест - // damageBonus = configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS || 0; - } + // Если бы были прямые бонусы к урону атаки от этих баффов, они бы рассчитывались здесь + // Например: + // if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH && configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS) { + // damageBonus = configToUse.NATURE_STRENGTH_ATTACK_DAMAGE_BONUS; + // } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK && configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS) { + // damageBonus = configToUse.ALMAGEST_ATTACK_BUFF_DAMAGE_BONUS; + // } + if (damageBonus > 0) { damage += damageBonus; attackBonusesLog.push(`урон +${damageBonus} от "${delayedAttackBuff.name}"`); } - // 2. Восстановление ресурса (для Силы Природы / Усиления Тьмой) - // Этот бонус (восстановление ресурса) срабатывает при каждой атаке, пока эффект активен let resourceRegenConfigKey = null; if (delayedAttackBuff.id === configToUse.ABILITY_ID_NATURE_STRENGTH) { resourceRegenConfigKey = 'NATURE_STRENGTH_MANA_REGEN'; } else if (delayedAttackBuff.id === configToUse.ABILITY_ID_ALMAGEST_BUFF_ATTACK) { - // Предположим, аналогичный конфиг для Альмагест, если она тоже регенит ресурс при атаке под баффом - resourceRegenConfigKey = 'ALMAGEST_DARK_ENERGY_REGEN'; + resourceRegenConfigKey = 'ALMAGEST_DARK_ENERGY_REGEN'; // Предположительный ключ } if (resourceRegenConfigKey && configToUse[resourceRegenConfigKey]) { @@ -80,12 +79,8 @@ function performAttack( configToUse.LOG_TYPE_HEAL ); } - // Не добавляем в attackBonusesLog, т.к. это отдельное событие, уже залогированное } } - - // Важно: НЕ МЕНЯЕМ здесь delayedAttackBuff.turnsLeft и НЕ УДАЛЯЕМ эффект. - // Его длительность будет уменьшаться в effectsLogic.processEffects каждый ход владельца эффекта. } // --- КОНЕЦ ПРОВЕРКИ И ПРИМЕНЕНИЯ ОТЛОЖЕННОГО БАФФА АТАКИ --- @@ -112,28 +107,29 @@ function performAttack( } // Применяем урон, убеждаемся, что HP не ниже нуля - const actualDamageDealtToHp = defenderState.currentHp - Math.max(0, Math.round(defenderState.currentHp - damage)); // Сколько HP реально отнято + const actualDamageDealtToHp = Math.min(defenderState.currentHp, damage); // Сколько HP реально отнято (не может быть больше текущего HP) defenderState.currentHp = Math.max(0, Math.round(defenderState.currentHp - damage)); // --- Насмешка от защищающегося (defenderState) в ответ на атаку --- if (getRandomTauntFunction && dataUtils) { - let reactionTauntTrigger = null; + let subTriggerForTaunt = null; if (wasBlocked) { - reactionTauntTrigger = 'onOpponentAttackBlocked'; - } else if (actualDamageDealtToHp > 0) { - reactionTauntTrigger = 'onOpponentAttackHit'; + subTriggerForTaunt = 'attackBlocked'; + } else if (actualDamageDealtToHp > 0) { // Если не было блока, но был нанесен урон + subTriggerForTaunt = 'attackHits'; } - // Можно добавить еще условие для промаха, если урон = 0 и не было блока + // Можно добавить еще условие для промаха, если урон = 0 и не было блока (и actualDamageDealtToHp === 0) + // else if (damage <= 0 && !wasBlocked) { subTriggerForTaunt = 'attackMissed'; } // Если есть такой триггер - if (reactionTauntTrigger) { + if (subTriggerForTaunt) { const attackerFullDataForTaunt = dataUtils.getCharacterData(attackerState.characterKey); if (attackerFullDataForTaunt) { const reactionTaunt = getRandomTauntFunction( - defenderState.characterKey, - reactionTauntTrigger, - {}, + defenderState.characterKey, // Говорящий (защитник) + 'onOpponentAction', // Главный триггер + subTriggerForTaunt, // Подтриггер: 'attackBlocked' или 'attackHits' configToUse, - attackerFullDataForTaunt, // Оппонент для говорящего (защитника) - это атакующий + attackerFullDataForTaunt, // Оппонент (атакующий) для говорящего currentGameState ); if (reactionTaunt && reactionTaunt !== "(Молчание)") { @@ -172,8 +168,8 @@ function applyAbilityEffect( getRandomTauntFunction, checkIfActionWasSuccessfulFunction // Пока не используется активно, outcome определяется внутри ) { - let abilityApplicationSucceeded = true; - let actionOutcomeForTaunt = null; // 'success' или 'fail' + let abilityApplicationSucceeded = true; // Флаг общего успеха применения способности + let actionOutcomeForTaunt = null; // 'success' или 'fail' для специфичных насмешек (например, Безмолвие Баларда) switch (ability.type) { case configToUse.ACTION_TYPE_HEAL: @@ -182,46 +178,69 @@ function applyAbilityEffect( if (actualHeal > 0) { casterState.currentHp = Math.round(casterState.currentHp + actualHeal); if (addToLogCallback) addToLogCallback(`💚 ${casterBaseStats.name} применяет "${ability.name}" и восстанавливает ${actualHeal} HP!`, configToUse.LOG_TYPE_HEAL); + actionOutcomeForTaunt = 'success'; // Для реакции оппонента, если таковая есть на хил } else { - if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения.`, configToUse.LOG_TYPE_INFO); + if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} применяет "${ability.name}", но не получает лечения (HP уже полное или хил = 0).`, configToUse.LOG_TYPE_INFO); abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; } break; case configToUse.ACTION_TYPE_DAMAGE: let damage = Math.floor(ability.power * (configToUse.DAMAGE_VARIATION_MIN + Math.random() * configToUse.DAMAGE_VARIATION_RANGE)); let wasAbilityBlocked = false; + let actualDamageDealtByAbility = 0; + if (targetState.isBlocking) { const initialDamage = damage; damage = Math.floor(damage * configToUse.BLOCK_DAMAGE_REDUCTION); wasAbilityBlocked = true; if (addToLogCallback) addToLogCallback(`🛡️ ${targetBaseStats.name} блокирует "${ability.name}" от ${casterBaseStats.name}! Урон снижен (${initialDamage} -> ${damage}).`, configToUse.LOG_TYPE_BLOCK); } + + actualDamageDealtByAbility = Math.min(targetState.currentHp, damage); targetState.currentHp = Math.max(0, Math.round(targetState.currentHp - damage)); + if (addToLogCallback && !wasAbilityBlocked) { addToLogCallback(`💥 ${casterBaseStats.name} применяет "${ability.name}" на ${targetBaseStats.name}, нанося ${damage} урона!`, configToUse.LOG_TYPE_DAMAGE); } - if (damage <= 0 && !wasAbilityBlocked) abilityApplicationSucceeded = false; + + if (damage <= 0 && !wasAbilityBlocked) { // Если урон нулевой и не было блока (например, из-за резистов, которых пока нет) + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } else if (wasAbilityBlocked) { + actionOutcomeForTaunt = 'blocked'; // Специальный исход для реакции на блок способности + } else if (actualDamageDealtByAbility > 0) { + actionOutcomeForTaunt = 'hit'; // Специальный исход для реакции на попадание способностью + } else { + actionOutcomeForTaunt = 'fail'; // Если урон 0 и не было блока (например цель уже мертва и 0 хп) + } break; case configToUse.ACTION_TYPE_BUFF: let effectDescriptionBuff = ability.description; if (typeof ability.descriptionFunction === 'function') { - effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); + effectDescriptionBuff = ability.descriptionFunction(configToUse, targetBaseStats); // targetBaseStats здесь может быть casterBaseStats, если бафф на себя } + // Обычно баффы накладываются на кастера casterState.activeEffects.push({ id: ability.id, name: ability.name, description: effectDescriptionBuff, type: ability.type, duration: ability.duration, - turnsLeft: ability.duration, // Эффект начнет тикать в конце текущего хода кастера + turnsLeft: ability.duration, grantsBlock: !!ability.grantsBlock, - isDelayed: !!ability.isDelayed, // Важно для "Силы Природы" - justCast: true // Помечаем, что только что наложен + isDelayed: !!ability.isDelayed, + justCast: true }); - if (ability.grantsBlock) require('./effectsLogic').updateBlockingStatus(casterState); + if (ability.grantsBlock && casterState.activeEffects.find(e => e.id === ability.id && e.grantsBlock)) { + // Требуется effectsLogic.updateBlockingStatus(casterState); + // но GameInstance вызывает его в switchTurn, так что здесь можно не дублировать, если эффект не мгновенный + } if (addToLogCallback) addToLogCallback(`✨ ${casterBaseStats.name} накладывает эффект "${ability.name}"!`, configToUse.LOG_TYPE_EFFECT); + actionOutcomeForTaunt = 'success'; // Для реакции оппонента, если бафф на себя break; case configToUse.ACTION_TYPE_DISABLE: + // Общее "полное безмолвие" от Елены или Альмагест if (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE || ability.id === configToUse.ABILITY_ID_ALMAGEST_DISABLE) { const effectIdFullSilence = ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest'; if (!targetState.activeEffects.some(e => e.id === effectIdFullSilence)) { @@ -230,7 +249,7 @@ function applyAbilityEffect( 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}! Способности цели заблокированы на ${ability.effectDuration} хода!`, configToUse.LOG_TYPE_EFFECT); actionOutcomeForTaunt = 'success'; } else { if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); @@ -238,19 +257,21 @@ function applyAbilityEffect( actionOutcomeForTaunt = 'fail'; } } + // Специальное Безмолвие Баларда else if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && casterState.characterKey === 'balard') { const success = Math.random() < configToUse.SILENCE_SUCCESS_RATE; - actionOutcomeForTaunt = success ? 'success' : 'fail'; + actionOutcomeForTaunt = success ? 'success' : 'fail'; // Этот outcome используется в tauntLogic if (success) { const targetAbilitiesList = dataUtils.getCharacterAbilities(targetState.characterKey); const availableAbilitiesToSilence = targetAbilitiesList.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}`) && + pa.id !== configToUse.ABILITY_ID_NONE // Исключаем "пустую" абилку, если она есть ); if (availableAbilitiesToSilence.length > 0) { const abilityToSilence = availableAbilitiesToSilence[Math.floor(Math.random() * availableAbilitiesToSilence.length)]; const turns = configToUse.SILENCE_DURATION; - targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); // +1 т.к. уменьшится в конце хода цели + targetState.disabledAbilities.push({ abilityId: abilityToSilence.id, turnsLeft: turns + 1 }); targetState.activeEffects.push({ id: `playerSilencedOn_${abilityToSilence.id}`, name: `Безмолвие: ${abilityToSilence.name}`, description: `Способность "${abilityToSilence.name}" временно недоступна.`, @@ -260,7 +281,7 @@ function applyAbilityEffect( if (addToLogCallback) addToLogCallback(`🔇 Эхо Безмолвия! "${abilityToSilence.name}" у ${targetBaseStats.name} заблокировано на ${turns} хода!`, configToUse.LOG_TYPE_EFFECT); } else { if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается наложить Безмолвие, но у ${targetBaseStats.name} нечего глушить!`, configToUse.LOG_TYPE_INFO); - actionOutcomeForTaunt = 'fail'; + actionOutcomeForTaunt = 'fail'; // Переопределяем, т.к. нечего было глушить } } else { if (addToLogCallback) addToLogCallback(`💨 Попытка ${casterBaseStats.name} наложить Безмолвие на ${targetBaseStats.name} провалилась!`, configToUse.LOG_TYPE_INFO); @@ -269,7 +290,7 @@ function applyAbilityEffect( break; case configToUse.ACTION_TYPE_DEBUFF: - const effectIdDebuff = 'effect_' + ability.id; + const effectIdDebuff = 'effect_' + ability.id; // Уникальный ID для дебаффа на цели if (!targetState.activeEffects.some(e => e.id === effectIdDebuff)) { let effectDescriptionDebuff = ability.description; if (typeof ability.descriptionFunction === 'function') { @@ -281,7 +302,7 @@ function applyAbilityEffect( duration: ability.effectDuration, turnsLeft: ability.effectDuration, power: ability.power, justCast: true }); - if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Ресурс будет сжигаться.`, configToUse.LOG_TYPE_EFFECT); + if (addToLogCallback) addToLogCallback(`📉 ${casterBaseStats.name} накладывает "${ability.name}" на ${targetBaseStats.name}! Эффект продлится ${ability.effectDuration} хода.`, configToUse.LOG_TYPE_EFFECT); actionOutcomeForTaunt = 'success'; } else { if (addToLogCallback) addToLogCallback(`${casterBaseStats.name} пытается применить "${ability.name}", но эффект уже активен на ${targetState.name}!`, configToUse.LOG_TYPE_INFO); @@ -290,65 +311,81 @@ function applyAbilityEffect( } break; - case configToUse.ACTION_TYPE_DRAIN: - if (casterState.characterKey === 'balard') { + case configToUse.ACTION_TYPE_DRAIN: // Пример для Манадрейна Баларда + if (casterState.characterKey === 'balard' && ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN) { let manaDrained = 0; let healthGained = 0; let damageDealtDrain = 0; if (ability.powerDamage > 0) { let baseDamageDrain = ability.powerDamage; - if (targetState.isBlocking) baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION); + if (targetState.isBlocking) { // Маловероятно, что дрейны блокируются, но для полноты + baseDamageDrain = Math.floor(baseDamageDrain * configToUse.BLOCK_DAMAGE_REDUCTION); + } damageDealtDrain = Math.max(0, baseDamageDrain); 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 = Math.max(0, Math.round(targetState.currentResource - actualDrain)); manaDrained = actualDrain; - const potentialHeal = Math.floor(manaDrained * ability.powerHealthGainFactor); + const potentialHeal = Math.floor(manaDrained * (ability.powerHealthGainFactor || 0)); // Убедимся, что фактор есть const actualHealGain = Math.min(potentialHeal, casterBaseStats.maxHp - casterState.currentHp); - casterState.currentHp = Math.round(casterState.currentHp + actualHealGain); - healthGained = actualHealGain; + if (actualHealGain > 0) { + casterState.currentHp = Math.round(casterState.currentHp + actualHealGain); + healthGained = actualHealGain; + } } + let logMsgDrain = `⚡ ${casterBaseStats.name} применяет "${ability.name}"! `; - if (damageDealtDrain > 0) logMsgDrain += `Наносит ${damageDealtDrain} урона. `; - 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} для похищения.`; + if (damageDealtDrain > 0) logMsgDrain += `Наносит ${damageDealtDrain} урона ${targetBaseStats.name}. `; + if (manaDrained > 0) { + logMsgDrain += `Вытягивает ${manaDrained} ${targetBaseStats.resourceName} у ${targetBaseStats.name}`; + if(healthGained > 0) logMsgDrain += ` и исцеляется на ${healthGained} HP!`; else logMsgDrain += `!`; + } else if (damageDealtDrain > 0) { + logMsgDrain += `${targetBaseStats.name} не имеет ${targetBaseStats.resourceName} для похищения.`; + } else { + logMsgDrain += `Не удалось ничего похитить у ${targetBaseStats.name}.`; + } + if (addToLogCallback) addToLogCallback(logMsgDrain, (manaDrained > 0 || damageDealtDrain > 0) ? configToUse.LOG_TYPE_DAMAGE : configToUse.LOG_TYPE_INFO); - if (manaDrained <= 0 && damageDealtDrain <=0 && healthGained <=0) abilityApplicationSucceeded = false; + + if (manaDrained <= 0 && damageDealtDrain <= 0 && healthGained <= 0) { + abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; + } else { + actionOutcomeForTaunt = 'success'; + } } break; default: if (addToLogCallback) addToLogCallback(`Неизвестный тип способности: ${ability?.type} для "${ability?.name}"`, configToUse.LOG_TYPE_SYSTEM); - console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type}`); + console.warn(`applyAbilityEffect: Неизвестный тип способности: ${ability?.type} для способности ${ability?.id}`); abilityApplicationSucceeded = false; + actionOutcomeForTaunt = 'fail'; } - // --- Насмешка от цели (targetState) в ответ на применение способности --- - // Вызываем только если способность не была нацелена на самого себя + // --- Насмешка от цели (targetState) в ответ на применение способности оппонентом (casterState) --- + // Вызываем только если способность не была нацелена на самого себя И есть функция насмешек if (getRandomTauntFunction && dataUtils && casterState.id !== targetState.id) { const casterFullDataForTaunt = dataUtils.getCharacterData(casterState.characterKey); if (casterFullDataForTaunt) { let tauntContext = { abilityId: ability.id }; - // Если для этой способности был определен исход (например, для безмолвия Баларда), используем его - if (actionOutcomeForTaunt) { + + // Если для этой способности был определен исход (например, для безмолвия Баларда, или попадание/блок урона) + // Используем actionOutcomeForTaunt, который мы установили в switch-case выше + if (actionOutcomeForTaunt === 'success' || actionOutcomeForTaunt === 'fail' || actionOutcomeForTaunt === 'blocked' || actionOutcomeForTaunt === 'hit') { tauntContext.outcome = actionOutcomeForTaunt; } - // Здесь можно было бы вызвать checkIfActionWasSuccessfulFunction, если бы он был и нужен для других способностей - // else if (checkIfActionWasSuccessfulFunction) { - // const success = checkIfActionWasSuccessfulFunction(ability, casterState, targetState, currentGameState, configToUse); - // tauntContext.outcome = success ? 'success' : 'fail'; - // } + // Для способностей типа DAMAGE, 'blocked' и 'hit' будут ключами в taunts.js (например, Elena onOpponentAction -> ABILITY_ID_ALMAGEST_DAMAGE -> blocked: [...]) + // Это не стандартные 'attackBlocked' и 'attackHits', а специфичные для реакции на *способность* + // Если вы хотите использовать общие 'attackBlocked'/'attackHits' и для способностей, вам нужно будет изменить логику в taunts.js + // или передавать здесь другие subTrigger'ы, если способность заблокирована/попала. - - // Вызываем насмешку, только если основное применение способности не считается полным провалом (опционально) - // Либо всегда вызываем, и пусть tauntLogic решает, есть ли реакция на "провальную" абилку - // if (abilityApplicationSucceeded || actionOutcomeForTaunt === 'fail') { // Например, реагируем даже на провал Эха Безмолвия const reactionTaunt = getRandomTauntFunction( targetState.characterKey, // Кто говорит (цель способности) 'onOpponentAction', // Триггер - tauntContext, // Контекст: ID способности и, возможно, outcome + tauntContext, // Контекст: ID способности кастера (оппонента) и, возможно, outcome configToUse, casterFullDataForTaunt, // Оппонент для говорящего - это кастер currentGameState @@ -356,7 +393,6 @@ function applyAbilityEffect( if (reactionTaunt && reactionTaunt !== "(Молчание)") { addToLogCallback(`${targetState.name}: "${reactionTaunt}"`, configToUse.LOG_TYPE_INFO); } - // } } } } @@ -364,39 +400,67 @@ function applyAbilityEffect( /** * Проверяет валидность использования способности. + * @param {object} ability - Объект способности. + * @param {object} casterState - Состояние бойца, который пытается применить способность. + * @param {object} targetState - Состояние цели (может быть тем же, что и casterState). + * @param {object} configToUse - Конфигурационный объект игры (GAME_CONFIG). + * @returns {object} - { isValid: boolean, reason: string|null } */ function checkAbilityValidity(ability, casterState, targetState, configToUse) { - // ... (код checkAbilityValidity без изменений, как вы предоставили) ... if (!ability) return { isValid: false, reason: "Способность не найдена." }; + if (casterState.currentResource < ability.cost) { - return { isValid: false, reason: `${casterState.name} пытается применить "${ability.name}", но не хватает ${casterState.resourceName}!` }; + return { isValid: false, reason: `${casterState.name} пытается применить "${ability.name}", но не хватает ${casterState.resourceName} (${casterState.currentResource}/${ability.cost})!` }; } + if ((casterState.abilityCooldowns?.[ability.id] || 0) > 0) { - return { isValid: false, reason: `"${ability.name}" еще на перезарядке.` }; + return { isValid: false, reason: `"${ability.name}" еще на перезарядке (${casterState.abilityCooldowns[ability.id]} х.).` }; } + + // Специальные кулдауны для Баларда if (casterState.characterKey === 'balard') { if (ability.id === configToUse.ABILITY_ID_BALARD_SILENCE && (casterState.silenceCooldownTurns || 0) > 0) { - return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке.` }; + return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке (${casterState.silenceCooldownTurns} х.).` }; } if (ability.id === configToUse.ABILITY_ID_BALARD_MANA_DRAIN && (casterState.manaDrainCooldownTurns || 0) > 0) { - return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке.` }; + return { isValid: false, reason: `"${ability.name}" (спец. КД) еще на перезарядке (${casterState.manaDrainCooldownTurns} х.).` }; } } + + // Проверка на безмолвие const isCasterFullySilenced = casterState.activeEffects.some(eff => eff.isFullSilence && eff.turnsLeft > 0); const isAbilitySpecificallySilenced = casterState.disabledAbilities?.some(dis => dis.abilityId === ability.id && dis.turnsLeft > 0); - if (isCasterFullySilenced || isAbilitySpecificallySilenced) { - return { isValid: false, reason: `${casterState.name} не может использовать способности из-за безмолвия!` }; + if (isCasterFullySilenced) { + return { isValid: false, reason: `${casterState.name} не может использовать способности из-за полного безмолвия!` }; } + if (isAbilitySpecificallySilenced) { + const specificSilenceEffect = casterState.disabledAbilities.find(dis => dis.abilityId === ability.id); + return { isValid: false, reason: `Способность "${ability.name}" у ${casterState.name} временно заблокирована (${specificSilenceEffect.turnsLeft} х.)!` }; + } + + + // Проверка наложения баффа, который уже активен (кроме обновляемых) if (ability.type === configToUse.ACTION_TYPE_BUFF && casterState.activeEffects.some(e => e.id === ability.id)) { - // Исключение для Силы Природы и Усиления Тьмой - их можно обновлять, если isDelayed - if (!ability.isDelayed) { - return { isValid: false, reason: `Эффект "${ability.name}" уже активен!` }; + // Исключение для "отложенных" баффов, которые можно обновлять (например, Сила Природы) + if (!ability.isDelayed) { // Если isDelayed не true, то нельзя обновлять. + return { isValid: false, reason: `Эффект "${ability.name}" уже активен у ${casterState.name}!` }; } } - const isTargetedDebuff = ability.id === configToUse.ABILITY_ID_SEAL_OF_WEAKNESS || ability.id === configToUse.ABILITY_ID_ALMAGEST_DEBUFF; - if (isTargetedDebuff && targetState.activeEffects.some(e => e.id === 'effect_' + ability.id)) { - return { isValid: false, reason: `Эффект "${ability.name}" уже наложен на ${targetState.name}!` }; + + // Проверка наложения дебаффа, который уже активен на цели + const isTargetedDebuff = ability.type === configToUse.ACTION_TYPE_DEBUFF || + (ability.type === configToUse.ACTION_TYPE_DISABLE && ability.id !== configToUse.ABILITY_ID_BALARD_SILENCE); // Безмолвие Баларда может пытаться наложиться повторно (и провалиться) + + if (isTargetedDebuff && targetState.id !== casterState.id) { // Убедимся, что это не бафф на себя, проверяемый как дебафф + const effectIdToCheck = (ability.type === configToUse.ACTION_TYPE_DISABLE && ability.id !== configToUse.ABILITY_ID_BALARD_SILENCE) ? + (ability.id === configToUse.ABILITY_ID_HYPNOTIC_GAZE ? 'fullSilenceByElena' : 'fullSilenceByAlmagest') : + ('effect_' + ability.id); + + if (targetState.activeEffects.some(e => e.id === effectIdToCheck)) { + return { isValid: false, reason: `Эффект "${ability.name}" уже наложен на ${targetState.name}!` }; + } } + return { isValid: true, reason: null }; }