Обработка ситуаций рекконекта. Доработка.

This commit is contained in:
PsiMagistr 2025-05-29 15:00:03 +03:00
parent 59ac3520f1
commit eaaf7ae14c
3 changed files with 325 additions and 257 deletions

View File

@ -1,6 +1,6 @@
// /server/game/instance/GameInstance.js // /server/game/instance/GameInstance.js
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const TurnTimer = require('./TurnTimer'); const TurnTimer = require('./TurnTimer'); // Убедитесь, что это новый TurnTimer.js
const gameLogic = require('../logic'); const gameLogic = require('../logic');
const dataUtils = require('../../data/dataUtils'); const dataUtils = require('../../data/dataUtils');
const GAME_CONFIG = require('../../core/config'); const GAME_CONFIG = require('../../core/config');
@ -27,16 +27,17 @@ class GameInstance {
GAME_CONFIG.TURN_DURATION_MS, GAME_CONFIG.TURN_DURATION_MS,
GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS, GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS,
() => this.handleTurnTimeout(), () => this.handleTurnTimeout(),
(remainingTime, isPlayerTurnForTimer, isPaused) => { // onTickCallback: (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic)
// Логируем отправку обновления таймера (remainingTime, isPlayerTurnForTimer, isTimerLogicPaused) => {
// console.log(`[GI TURN_TIMER_CB ${this.id}] Sending update. Remaining: ${remainingTime}, isPlayerT: ${isPlayerTurnForTimer}, isPaused (raw): ${isPaused}, effectivelyPaused: ${this.isGameEffectivelyPaused()}`); const socketsInRoom = Array.from(this.io.sockets.adapter.rooms.get(this.id) || []);
console.log(`[GI TURN_TIMER_UPDATE_CB ${this.id}] Called! To room ${this.id} (sockets: ${socketsInRoom.join(', ')}). Remaining: ${remainingTime}, isPlayerT_forTimer: ${isPlayerTurnForTimer}, isTimerLogicPaused: ${isTimerLogicPaused}, isGameEffectivelyPaused(GI): ${this.isGameEffectivelyPaused()}`);
this.io.to(this.id).emit('turnTimerUpdate', { this.io.to(this.id).emit('turnTimerUpdate', {
remainingTime, remainingTime,
isPlayerTurn: isPlayerTurnForTimer, isPlayerTurn: isPlayerTurnForTimer, // Чей ход с точки зрения таймера
isPaused: isPaused || this.isGameEffectivelyPaused() isPaused: isTimerLogicPaused || this.isGameEffectivelyPaused() // Общая пауза
}); });
}, },
this.id this.id // gameIdForLogs
); );
if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') {
@ -61,8 +62,8 @@ class GameInstance {
return this.playerConnectionHandler.addPlayer(socket, chosenCharacterKey, identifier); return this.playerConnectionHandler.addPlayer(socket, chosenCharacterKey, identifier);
} }
removePlayer(socketId, reason) { removePlayer(socketId, reason) { // Вызывается из PCH
this.playerConnectionHandler.removePlayer(socketId, reason); // PCH сам обрабатывает удаление, GameInstance реагирует через handlePlayerPermanentlyLeft
} }
handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) { handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) {
@ -78,7 +79,7 @@ class GameInstance {
this.playerConnectionHandler.clearAllReconnectTimers(); this.playerConnectionHandler.clearAllReconnectTimers();
} }
isGameEffectivelyPaused() { isGameEffectivelyPaused() { // Определяет, приостановлена ли игра из-за дисконнектов
return this.playerConnectionHandler.isGameEffectivelyPaused(); return this.playerConnectionHandler.isGameEffectivelyPaused();
} }
@ -88,6 +89,7 @@ class GameInstance {
if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) { if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) {
this.endGameDueToDisconnect(playerRole, characterKey, "player_left_ai_game"); this.endGameDueToDisconnect(playerRole, characterKey, "player_left_ai_game");
} else if (this.mode === 'pvp') { } else if (this.mode === 'pvp') {
// playerCount уже должен быть обновлен в PCH
if (this.playerCount < 2) { if (this.playerCount < 2) {
const remainingActivePlayerEntry = Object.values(this.players).find(p => p.id !== playerRole && !p.isTemporarilyDisconnected); const remainingActivePlayerEntry = Object.values(this.players).find(p => p.id !== playerRole && !p.isTemporarilyDisconnected);
this.endGameDueToDisconnect(playerRole, characterKey, "opponent_left_pvp_game", remainingActivePlayerEntry?.id); this.endGameDueToDisconnect(playerRole, characterKey, "opponent_left_pvp_game", remainingActivePlayerEntry?.id);
@ -147,16 +149,13 @@ class GameInstance {
const p1ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); const p1ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
const p2ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected); const p2ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected);
// Устанавливаем ключи персонажей, если они еще не установлены, на основе активных игроков в PCH
// Это важно, если initializeGame вызывается до того, как PCH успел обновить ключи в GI через сеттеры
if (p1ActiveEntry && !this.playerCharacterKey) this.playerCharacterKey = p1ActiveEntry.chosenCharacterKey; if (p1ActiveEntry && !this.playerCharacterKey) this.playerCharacterKey = p1ActiveEntry.chosenCharacterKey;
if (p2ActiveEntry && !this.opponentCharacterKey && this.mode === 'pvp') this.opponentCharacterKey = p2ActiveEntry.chosenCharacterKey; if (p2ActiveEntry && !this.opponentCharacterKey && this.mode === 'pvp') this.opponentCharacterKey = p2ActiveEntry.chosenCharacterKey;
if (this.mode === 'ai') { if (this.mode === 'ai') {
if (!p1ActiveEntry) { this._handleCriticalError('init_ai_no_active_player_gi', 'Инициализация AI игры: Игрок-человек не найден или не активен.'); return false; } if (!p1ActiveEntry) { this._handleCriticalError('init_ai_no_active_player_gi', 'Инициализация AI игры: Игрок-человек не найден или не активен.'); return false; }
if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'Инициализация AI игры: Ключ персонажа игрока не установлен.'); return false;} if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'Инициализация AI игры: Ключ персонажа игрока не установлен.'); return false;}
this.opponentCharacterKey = 'balard'; if (!this.opponentCharacterKey) this.opponentCharacterKey = 'balard'; // Устанавливаем AI, если еще не установлен
} else { // pvp } else { // pvp
if (this.playerCount === 1 && p1ActiveEntry && !this.playerCharacterKey) { if (this.playerCount === 1 && p1ActiveEntry && !this.playerCharacterKey) {
this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP инициализация (1 игрок): Ключ персонажа игрока отсутствует.'); return false; this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP инициализация (1 игрок): Ключ персонажа игрока отсутствует.'); return false;
@ -179,7 +178,6 @@ class GameInstance {
this.logBuffer = []; this.logBuffer = [];
// Имена берутся из playerData/opponentData, если они есть. PCH обновит их при реконнекте, если они изменились.
const playerName = playerData?.baseStats?.name || (p1ActiveEntry?.name || 'Ожидание Игрока 1...'); const playerName = playerData?.baseStats?.name || (p1ActiveEntry?.name || 'Ожидание Игрока 1...');
let opponentName; let opponentName;
if (this.mode === 'ai') { if (this.mode === 'ai') {
@ -188,13 +186,12 @@ class GameInstance {
opponentName = opponentData?.baseStats?.name || (p2ActiveEntry?.name || 'Ожидание Игрока 2...'); opponentName = opponentData?.baseStats?.name || (p2ActiveEntry?.name || 'Ожидание Игрока 2...');
} }
this.gameState = { this.gameState = {
player: isPlayerSlotFilledAndActive ? player: isPlayerSlotFilledAndActive ?
this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) : // Передаем имя this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) :
this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: playerName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], playerName), this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: playerName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], playerName),
opponent: isOpponentSlotFilledAndActive ? opponent: isOpponentSlotFilledAndActive ?
this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) : // Передаем имя this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) :
this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: opponentName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], opponentName), this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: opponentName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], opponentName),
isPlayerTurn: (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) ? (Math.random() < 0.5) : true, isPlayerTurn: (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) ? (Math.random() < 0.5) : true,
isGameOver: false, isGameOver: false,
@ -207,7 +204,7 @@ class GameInstance {
_createFighterState(roleId, baseStats, abilities, explicitName = null) { _createFighterState(roleId, baseStats, abilities, explicitName = null) {
const fighterState = { const fighterState = {
id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name, // Используем explicitName если передано id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name,
currentHp: baseStats.maxHp, maxHp: baseStats.maxHp, currentHp: baseStats.maxHp, maxHp: baseStats.maxHp,
currentResource: baseStats.maxResource, maxResource: baseStats.maxResource, currentResource: baseStats.maxResource, maxResource: baseStats.maxResource,
resourceName: baseStats.resourceName, attackPower: baseStats.attackPower, resourceName: baseStats.resourceName, attackPower: baseStats.attackPower,
@ -234,7 +231,7 @@ class GameInstance {
if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) { if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) {
console.warn(`[GameInstance ${this.id}] startGame: gameState или ключи персонажей не полностью инициализированы. Попытка повторной инициализации.`); console.warn(`[GameInstance ${this.id}] startGame: gameState или ключи персонажей не полностью инициализированы. Попытка повторной инициализации.`);
if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { // initializeGame сама установит gameState
this._handleCriticalError('start_game_reinit_failed_sg_gi', 'Повторная инициализация перед стартом не удалась или ключи все еще отсутствуют в gameState.'); this._handleCriticalError('start_game_reinit_failed_sg_gi', 'Повторная инициализация перед стартом не удалась или ключи все еще отсутствуют в gameState.');
return; return;
} }
@ -249,12 +246,9 @@ class GameInstance {
return; return;
} }
// Обновляем имена в gameState на основе данных персонажей перед отправкой клиентам
// Это гарантирует, что имена из dataUtils (самые "правильные") попадут в первое gameStarted
if (this.gameState.player && pData?.baseStats?.name) this.gameState.player.name = pData.baseStats.name; if (this.gameState.player && pData?.baseStats?.name) this.gameState.player.name = pData.baseStats.name;
if (this.gameState.opponent && oData?.baseStats?.name) this.gameState.opponent.name = oData.baseStats.name; if (this.gameState.opponent && oData?.baseStats?.name) this.gameState.opponent.name = oData.baseStats.name;
this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM);
if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) {
@ -288,7 +282,7 @@ class GameInstance {
this.broadcastLogUpdate(); this.broadcastLogUpdate();
const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn;
console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn: ${this.gameState.isPlayerTurn}, isFirstTurnAi: ${isFirstTurnAi}`); console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn(GS): ${this.gameState.isPlayerTurn}, isAiMakingMove(to timer): ${isFirstTurnAi}`);
this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi); this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi);
if (isFirstTurnAi) { if (isFirstTurnAi) {
@ -311,7 +305,10 @@ class GameInstance {
actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."}); actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."});
return; return;
} }
if (!this.gameState || this.gameState.isGameOver) { return; } if (!this.gameState || this.gameState.isGameOver) {
console.warn(`[GameInstance ${this.id}] processPlayerAction: Действие от ${identifier} проигнорировано (нет gameState или игра завершена). GameOver: ${this.gameState?.isGameOver}`);
return;
}
const actingPlayerRole = actingPlayerInfo.id; const actingPlayerRole = actingPlayerInfo.id;
const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) ||
@ -323,8 +320,11 @@ class GameInstance {
return; return;
} }
console.log(`[GameInstance ${this.id}] Ход корректен. Очистка таймера.`); console.log(`[GameInstance ${this.id}] Ход корректен для ${identifier}. Очистка таймера.`);
if(this.turnTimer.isActive()) this.turnTimer.clear(); if(this.turnTimer.isActive() || this.turnTimer.isPaused()) { // Очищаем, даже если на паузе, т.к. действие совершено
this.turnTimer.clear();
}
const attackerState = this.gameState[actingPlayerRole]; const attackerState = this.gameState[actingPlayerRole];
const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
@ -365,12 +365,14 @@ class GameInstance {
} }
if (this.checkGameOver()) return; if (this.checkGameOver()) return;
this.broadcastLogUpdate(); this.broadcastLogUpdate(); // Отправляем лог сразу после действия
if (actionIsValidAndPerformed) { if (actionIsValidAndPerformed) {
// Небольшая задержка перед сменой хода, чтобы клиент успел увидеть результат действия
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
} else { } else {
// Если действие было невалидным, перезапускаем таймер для текущего игрока
const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn; const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn;
console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера. isPlayerTurn: ${this.gameState.isPlayerTurn}, isAiTurnForTimer: ${isAiTurnForTimer}`); console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера для ${identifier}. isPlayerTurn(GS): ${this.gameState.isPlayerTurn}, isAiMakingMove(to timer): ${isAiTurnForTimer}`);
this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer); this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer);
} }
} }
@ -379,7 +381,12 @@ class GameInstance {
console.log(`[GameInstance ${this.id}] Попытка смены хода. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}`); console.log(`[GameInstance ${this.id}] Попытка смены хода. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}`);
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Смена хода отложена: игра на паузе.`); return; } if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Смена хода отложена: игра на паузе.`); return; }
if (!this.gameState || this.gameState.isGameOver) { return; } if (!this.gameState || this.gameState.isGameOver) { return; }
if(this.turnTimer.isActive()) this.turnTimer.clear();
// Таймер хода должен быть уже очищен в processPlayerAction или processAiTurn
// Но на всякий случай, если switchTurn вызван из другого места (например, после эффектов)
if(this.turnTimer.isActive() || this.turnTimer.isPaused()) {
this.turnTimer.clear();
}
const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
const endingTurnActorState = this.gameState[endingTurnActorRole]; const endingTurnActorState = this.gameState[endingTurnActorRole];
@ -403,14 +410,14 @@ class GameInstance {
if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Состояние или имя текущего актора недействительны.`); return; } if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Состояние или имя текущего актора недействительны.`); return; }
this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN);
this.broadcastGameStateUpdate(); this.broadcastGameStateUpdate(); // Отправляем обновленное состояние и все накопленные логи
const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole); const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole);
if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) { if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) {
console.log(`[GameInstance ${this.id}] Ход перешел к ${currentTurnActorRole}, но игрок ${currentTurnPlayerEntry.identifier} отключен. Таймер не запущен switchTurn.`); console.log(`[GameInstance ${this.id}] Ход перешел к ${currentTurnActorRole}, но игрок ${currentTurnPlayerEntry.identifier} отключен. Таймер не запущен switchTurn.`);
} else { } else {
const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn;
console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn: ${this.gameState.isPlayerTurn}, isNextTurnAi: ${isNextTurnAi}`); console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn(GS): ${this.gameState.isPlayerTurn}, isAiMakingMove(to timer): ${isNextTurnAi}`);
this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi); this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi);
if (isNextTurnAi) { if (isNextTurnAi) {
setTimeout(() => { setTimeout(() => {
@ -423,7 +430,7 @@ class GameInstance {
} }
processAiTurn() { processAiTurn() {
console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn: ${this.gameState?.isPlayerTurn}`); console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn(GS): ${this.gameState?.isPlayerTurn}`);
if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Ход AI отложен: игра на паузе.`); return; } if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Ход AI отложен: игра на паузе.`); return; }
if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; } if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; }
if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) { if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) {
@ -431,7 +438,10 @@ class GameInstance {
this.switchTurn(); this.switchTurn();
return; return;
} }
if(this.turnTimer.isActive()) this.turnTimer.clear();
if(this.turnTimer.isActive() || this.turnTimer.isPaused()) { // Очищаем таймер, так как AI сейчас сделает ход
this.turnTimer.clear();
}
const aiState = this.gameState.opponent; const aiState = this.gameState.opponent;
const playerState = this.gameState.player; const playerState = this.gameState.player;
@ -457,7 +467,7 @@ class GameInstance {
} }
if (this.checkGameOver()) return; if (this.checkGameOver()) return;
this.broadcastLogUpdate(); this.broadcastLogUpdate(); // Отправляем лог после действия AI
if (actionIsValidAndPerformedForAI) { if (actionIsValidAndPerformedForAI) {
setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION);
} else { } else {
@ -486,7 +496,7 @@ class GameInstance {
const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode); const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode);
if (gameOverResult.isOver) { if (gameOverResult.isOver) {
this.gameState.isGameOver = true; this.gameState.isGameOver = true;
if(this.turnTimer.isActive()) this.turnTimer.clear(); if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear(); // Очищаем таймер, если игра окончена
this.clearAllReconnectTimers(); this.clearAllReconnectTimers();
this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM);
@ -513,7 +523,7 @@ class GameInstance {
endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) { endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) {
if (this.gameState && !this.gameState.isGameOver) { if (this.gameState && !this.gameState.isGameOver) {
this.gameState.isGameOver = true; this.gameState.isGameOver = true;
if(this.turnTimer.isActive()) this.turnTimer.clear(); if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear();
this.clearAllReconnectTimers(); this.clearAllReconnectTimers();
let actualWinnerRole = winnerIfAny; let actualWinnerRole = winnerIfAny;
@ -583,7 +593,7 @@ class GameInstance {
this.addToLog(`Игрок покинул AI игру до ее полного начала.`, GAME_CONFIG.LOG_TYPE_SYSTEM); this.addToLog(`Игрок покинул AI игру до ее полного начала.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
} }
if (this.turnTimer.isActive()) this.turnTimer.clear(); if (this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear();
this.clearAllReconnectTimers(); this.clearAllReconnectTimers();
this.io.to(this.id).emit('gameOver', { this.io.to(this.id).emit('gameOver', {
@ -634,7 +644,7 @@ class GameInstance {
const winnerCharKey = this.gameState[winnerRole]?.characterKey; const winnerCharKey = this.gameState[winnerRole]?.characterKey;
this.gameState.isGameOver = true; this.gameState.isGameOver = true;
if(this.turnTimer.isActive()) this.turnTimer.clear(); if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear();
this.clearAllReconnectTimers(); this.clearAllReconnectTimers();
this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM); this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM);
@ -654,8 +664,10 @@ class GameInstance {
handleTurnTimeout() { handleTurnTimeout() {
if (!this.gameState || this.gameState.isGameOver) return; if (!this.gameState || this.gameState.isGameOver) return;
console.log(`[GameInstance ${this.id}] Произошел таймаут хода.`); console.log(`[GameInstance ${this.id}] Произошел таймаут хода (вызван из TurnTimer).`);
const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const timedOutPlayerRole = this.turnTimer.isConfiguredForPlayerSlotTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID;
// Используем isConfiguredForPlayerSlotTurn из таймера, т.к. gameState.isPlayerTurn мог измениться до фактического вызова этого коллбэка
// или если таймаут произошел во время "думания" AI (хотя таймер AI не должен вызывать этот коллбэк для игрока).
const winnerPlayerRoleIfHuman = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const winnerPlayerRoleIfHuman = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
let winnerActuallyExists = false; let winnerActuallyExists = false;
@ -670,6 +682,7 @@ class GameInstance {
const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerActuallyExists ? winnerPlayerRoleIfHuman : null, timedOutPlayerRole); const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerActuallyExists ? winnerPlayerRoleIfHuman : null, timedOutPlayerRole);
this.gameState.isGameOver = true; this.gameState.isGameOver = true;
// turnTimer.clear() уже должен был быть вызван внутри TurnTimer перед onTimeoutCallback, или будет вызван в checkGameOver
this.clearAllReconnectTimers(); this.clearAllReconnectTimers();
this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM);
@ -694,7 +707,7 @@ class GameInstance {
this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode }; this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode };
} }
if(this.turnTimer.isActive()) this.turnTimer.clear(); if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear();
this.clearAllReconnectTimers(); this.clearAllReconnectTimers();
this.addToLog(`Критическая ошибка сервера: ${logMessage}. Игра будет завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM); this.addToLog(`Критическая ошибка сервера: ${logMessage}. Игра будет завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM);
@ -711,8 +724,6 @@ class GameInstance {
addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) {
if (!message) return; if (!message) return;
this.logBuffer.push({ message, type, timestamp: Date.now() }); this.logBuffer.push({ message, type, timestamp: Date.now() });
// Раскомментируйте для немедленной отправки логов, если нужно (но обычно лучше батчинг)
// this.broadcastLogUpdate();
} }
consumeLogBuffer() { consumeLogBuffer() {
@ -730,7 +741,7 @@ class GameInstance {
console.warn(`[GameInstance ${this.id}] broadcastGameStateUpdate: gameState отсутствует.`); console.warn(`[GameInstance ${this.id}] broadcastGameStateUpdate: gameState отсутствует.`);
return; return;
} }
console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn: ${this.gameState.isPlayerTurn}`); console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn(GS): ${this.gameState.isPlayerTurn}`);
this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() });
} }
@ -740,7 +751,7 @@ class GameInstance {
if (systemLogs.length > 0) { if (systemLogs.length > 0) {
this.io.to(this.id).emit('logUpdate', { log: systemLogs }); this.io.to(this.id).emit('logUpdate', { log: systemLogs });
} }
this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); // Оставляем несистемные this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM);
return; return;
} }
if (this.logBuffer.length > 0) { if (this.logBuffer.length > 0) {

View File

@ -29,31 +29,25 @@ class PlayerConnectionHandler {
socket.emit('gameError', { message: 'Эта игра уже завершена.' }); socket.emit('gameError', { message: 'Эта игра уже завершена.' });
return false; return false;
} }
// Если игрок уже есть, и это не временное отключение, и сокет другой - это F5 или новая вкладка. // Делегируем handlePlayerReconnected, который разберется, новый ли это сокет или тот же.
// GameManager должен был направить на handleRequestGameState, который вызовет handlePlayerReconnected.
// Прямой addPlayer в этом случае - редкий сценарий, но handlePlayerReconnected его обработает.
return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket); return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket);
} }
if (Object.keys(this.players).length >= 2 && this.playerCount >=2 && this.mode === 'pvp') { // В AI режиме только 1 человек // Проверка на максимальное количество игроков
socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); if (this.mode === 'pvp' && this.playerCount >= 2) {
socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' });
return false; return false;
} }
if (this.mode === 'ai' && this.playerCount >=1) { if (this.mode === 'ai' && this.playerCount >= 1) {
socket.emit('gameError', { message: 'К AI игре может присоединиться только один игрок.'}); socket.emit('gameError', { message: 'К AI игре может присоединиться только один игрок.'});
return false; return false;
} }
let assignedPlayerId; let assignedPlayerId;
let actualCharacterKey = chosenCharacterKey || 'elena'; let actualCharacterKey = chosenCharacterKey || 'elena';
const charData = dataUtils.getCharacterData(actualCharacterKey); const charDataForName = dataUtils.getCharacterData(actualCharacterKey); // Для имени
if (this.mode === 'ai') { if (this.mode === 'ai') {
// if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Эта проверка уже покрыта playerCount >= 1 выше
// socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' });
// return false;
// }
assignedPlayerId = GAME_CONFIG.PLAYER_ID; assignedPlayerId = GAME_CONFIG.PLAYER_ID;
} else { // pvp } else { // pvp
if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) { if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) {
@ -62,39 +56,53 @@ class PlayerConnectionHandler {
assignedPlayerId = GAME_CONFIG.OPPONENT_ID; assignedPlayerId = GAME_CONFIG.OPPONENT_ID;
const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) { if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) {
if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest'; const allKeys = dataUtils.getAllCharacterKeys ? dataUtils.getAllCharacterKeys() : ['elena', 'almagest', 'balard'];
else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena'; const otherKey = allKeys.find(k => k !== firstPlayerInfo.chosenCharacterKey && k !== 'balard'); // Не даем Баларда второму игроку по умолчанию
else actualCharacterKey = dataUtils.getAllCharacterKeys().find(k => k !== firstPlayerInfo.chosenCharacterKey) || 'elena'; actualCharacterKey = otherKey || (actualCharacterKey === 'elena' ? 'almagest' : 'elena'); // Фоллбэк
} }
} else { // Оба слота заняты, но playerCount мог быть < 2 если кто-то в процессе дисконнекта } else {
socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре (возможно, все заняты или в процессе переподключения).' }); socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' });
return false; return false;
} }
} }
// Если для этой роли УЖЕ был игрок (например, старый сокет при F5 до того, как сработал disconnect), // Удаление старой записи, если сокет для этой роли уже существует, но с другим ID
// то handlePlayerReconnected должен был бы это обработать. Этот блок здесь - подстраховка, // (на случай очень быстрой смены сокета до срабатывания disconnect)
// если addPlayer вызван напрямую в таком редком случае. const oldPlayerSocketEntry = Object.entries(this.players).find(([sid, pInfo]) => pInfo.id === assignedPlayerId);
const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId && this.players[sid].socket?.id !== socket.id); if (oldPlayerSocketEntry) {
if (oldPlayerSocketIdForRole) { const [oldSocketId, oldPlayerInfo] = oldPlayerSocketEntry;
const oldPlayerInfo = this.players[oldPlayerSocketIdForRole]; if (oldPlayerInfo.socket && oldPlayerInfo.socket.id !== socket.id) {
console.warn(`[PCH ${this.gameId}] addPlayer: Найден старый сокет ${oldPlayerInfo.socket?.id} для роли ${assignedPlayerId}. Удаляем его запись.`); console.warn(`[PCH ${this.gameId}] addPlayer: Найдена старая запись для роли ${assignedPlayerId} с сокетом ${oldPlayerInfo.socket.id}. Новый сокет: ${socket.id}. Удаляем старую запись.`);
if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); oldPlayerInfo.socket.disconnect(true); } catch(e){} } try {
delete this.players[oldPlayerSocketIdForRole]; if (oldPlayerInfo.socket.connected) oldPlayerInfo.socket.disconnect(true);
} catch (e) { console.error(`[PCH ${this.gameId}] Ошибка при дисконнекте старого сокета: ${e.message}`); }
delete this.players[oldSocketId];
if (this.playerSockets[assignedPlayerId] === oldPlayerInfo.socket) {
delete this.playerSockets[assignedPlayerId];
}
// Не уменьшаем playerCount здесь, так как это замена, а не уход
}
} }
this.players[socket.id] = { this.players[socket.id] = {
id: assignedPlayerId, id: assignedPlayerId,
socket: socket, socket: socket,
chosenCharacterKey: actualCharacterKey, chosenCharacterKey: actualCharacterKey,
identifier: identifier, identifier: identifier,
isTemporarilyDisconnected: false, isTemporarilyDisconnected: false,
name: charData?.baseStats?.name || actualCharacterKey name: charDataForName?.baseStats?.name || actualCharacterKey
}; };
this.playerSockets[assignedPlayerId] = socket; this.playerSockets[assignedPlayerId] = socket;
this.playerCount++; this.playerCount++; // Увеличиваем счетчик активных игроков
socket.join(this.gameId);
console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`); try {
socket.join(this.gameId);
console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`);
} catch (e) {
console.error(`[PCH ${this.gameId}] КРИТИЧЕСКАЯ ОШИБКА при socket.join: ${e.message}. Игрок ${identifier} может не получать широковещательные сообщения.`);
// Возможно, стоит откатить добавление игрока или вернуть false
}
if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey); if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey);
@ -116,7 +124,7 @@ class PlayerConnectionHandler {
console.log(`[PCH ${this.gameId}] Окончательное удаление игрока ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Причина: ${reason}.`); console.log(`[PCH ${this.gameId}] Окончательное удаление игрока ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Причина: ${reason}.`);
if (playerInfo.socket) { if (playerInfo.socket) {
try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave: ${e.message}`); } try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave в removePlayer: ${e.message}`); }
} }
if (!playerInfo.isTemporarilyDisconnected) { if (!playerInfo.isTemporarilyDisconnected) {
@ -143,19 +151,17 @@ class PlayerConnectionHandler {
if (!playerEntry || !playerEntry.socket) { if (!playerEntry || !playerEntry.socket) {
console.warn(`[PCH ${this.gameId}] Запись игрока или сокет не найдены для ${identifier} (роль ${playerIdRole}) во время потенциального выхода. disconnectedSocketId: ${disconnectedSocketId}`); console.warn(`[PCH ${this.gameId}] Запись игрока или сокет не найдены для ${identifier} (роль ${playerIdRole}) во время потенциального выхода. disconnectedSocketId: ${disconnectedSocketId}`);
// Если записи нет, возможно, игрок уже удален или это был очень старый сокет.
// Проверим, есть ли запись по disconnectedSocketId, и если да, удалим ее.
if (this.players[disconnectedSocketId]) { if (this.players[disconnectedSocketId]) {
console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId}, удаляем ее.`); console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId} (без playerEntry по роли/id), удаляем ее.`);
this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_entry'); this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_main_entry');
} }
return; return;
} }
if (playerEntry.socket.id !== disconnectedSocketId) { if (playerEntry.socket.id !== disconnectedSocketId) {
console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игрок, вероятно, уже переподключился или сессия обновлена. Игнорируем дальнейшую логику "потенциального выхода" для этого устаревшего сокета.`); console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игнорируем.`);
if (this.players[disconnectedSocketId]) { if (this.players[disconnectedSocketId]) {
delete this.players[disconnectedSocketId]; // Удаляем только эту запись, не вызываем полный removePlayer delete this.players[disconnectedSocketId];
} }
return; return;
} }
@ -170,7 +176,7 @@ class PlayerConnectionHandler {
} }
playerEntry.isTemporarilyDisconnected = true; playerEntry.isTemporarilyDisconnected = true;
this.playerCount--; this.playerCount--; // Уменьшаем счетчик активных
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (роль ${playerIdRole}, сокет ${disconnectedSocketId}) временно отключен. Активных: ${this.playerCount}. Запускаем таймер переподключения.`); console.log(`[PCH ${this.gameId}] Игрок ${identifier} (роль ${playerIdRole}, сокет ${disconnectedSocketId}) временно отключен. Активных: ${this.playerCount}. Запускаем таймер переподключения.`);
const disconnectedName = playerEntry.name || this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`; const disconnectedName = playerEntry.name || this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`;
@ -188,22 +194,30 @@ class PlayerConnectionHandler {
}); });
} }
if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isConfiguredForAiMove))) { if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || this.gameInstance.turnTimer.getIsConfiguredForAiMove?.())) {
this.pausedTurnState = this.gameInstance.turnTimer.pause(); this.pausedTurnState = this.gameInstance.turnTimer.pause();
console.log(`[PCH ${this.gameId}] Таймер хода приостановлен из-за отключения. Состояние:`, JSON.stringify(this.pausedTurnState)); console.log(`[PCH ${this.gameId}] Таймер хода приостановлен из-за отключения. Состояние:`, JSON.stringify(this.pausedTurnState));
} else { } else {
this.pausedTurnState = null; this.pausedTurnState = null;
} }
this.clearReconnectTimer(playerIdRole); this.clearReconnectTimer(playerIdRole); // Очищаем старый, если был
const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000; const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000;
const reconnectStartTime = Date.now(); const reconnectStartTime = Date.now();
const updateInterval = setInterval(() => { const updateInterval = setInterval(() => {
const timerInfo = this.reconnectTimers[playerIdRole];
if (!timerInfo || timerInfo.timerId === null) { // Если основной таймер уже сработал/очищен
if (timerInfo?.updateIntervalId) clearInterval(timerInfo.updateIntervalId);
if (timerInfo) timerInfo.updateIntervalId = null;
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 });
return;
}
const remaining = reconnectDuration - (Date.now() - reconnectStartTime); const remaining = reconnectDuration - (Date.now() - reconnectStartTime);
if (remaining <= 0 || !this.reconnectTimers[playerIdRole] || this.reconnectTimers[playerIdRole]?.timerId === null) { // Добавлена проверка на существование таймера if (remaining <= 0) {
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); // Даем основному setTimeout сработать, здесь просто останавливаем интервал тиков
if (this.reconnectTimers[playerIdRole]) this.reconnectTimers[playerIdRole].updateIntervalId = null; // Помечаем, что интервал очищен clearInterval(timerInfo.updateIntervalId);
timerInfo.updateIntervalId = null;
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 }); this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 });
return; return;
} }
@ -211,15 +225,24 @@ class PlayerConnectionHandler {
}, 1000); }, 1000);
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (this.reconnectTimers[playerIdRole]?.updateIntervalId) { // Очищаем интервал, если он еще существует const timerInfo = this.reconnectTimers[playerIdRole];
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); if (timerInfo?.updateIntervalId) {
this.reconnectTimers[playerIdRole].updateIntervalId = null; clearInterval(timerInfo.updateIntervalId);
timerInfo.updateIntervalId = null;
} }
this.reconnectTimers[playerIdRole].timerId = null; // Помечаем, что основной таймаут сработал или очищен if (timerInfo) timerInfo.timerId = null; // Помечаем, что сработал
// this.clearReconnectTimer(playerIdRole) здесь вызовет сам себя рекурсивно, если удалить delete this.reconnectTimers[playerIdRole];
// Поэтому просто удаляем запись, т.к. таймеры уже очищены или помечены.
if (this.reconnectTimers[playerIdRole]) delete this.reconnectTimers[playerIdRole];
const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier);
if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) { if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) {
console.log(`[PCH ${this.gameId}] Таймаут переподключения для ${identifier}. Удаляем игрока.`);
this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout"); this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout");
} else {
console.log(`[PCH ${this.gameId}] Таймаут переподключения для ${identifier}, но игрок уже не (или не был) isTemporarilyDisconnected.`);
} }
}, reconnectDuration); }, reconnectDuration);
this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration }; this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration };
@ -239,41 +262,41 @@ class PlayerConnectionHandler {
if (playerEntry) { if (playerEntry) {
const oldSocket = playerEntry.socket; const oldSocket = playerEntry.socket;
const wasTemporarilyDisconnected = playerEntry.isTemporarilyDisconnected;
// Обновляем сокет в playerEntry и в this.players / this.playerSockets, если сокет новый
if (oldSocket && oldSocket.id !== newSocket.id) { if (oldSocket && oldSocket.id !== newSocket.id) {
console.log(`[PCH ${this.gameId}] New socket ${newSocket.id} for player ${identifier}. Old socket: ${oldSocket.id}. Updating records.`); console.log(`[PCH ${this.gameId}] Новый сокет ${newSocket.id} для игрока ${identifier}. Старый сокет: ${oldSocket.id}. Обновляем записи.`);
if (this.players[oldSocket.id]) delete this.players[oldSocket.id]; // Удаляем старую запись по старому socket.id if (this.players[oldSocket.id]) delete this.players[oldSocket.id];
if (oldSocket.connected) { // Пытаемся корректно закрыть старый сокет if (oldSocket.connected) {
console.log(`[PCH ${this.gameId}] Disconnecting old stale socket ${oldSocket.id}.`); console.log(`[PCH ${this.gameId}] Отключаем старый "подвисший" сокет ${oldSocket.id}.`);
oldSocket.disconnect(true); oldSocket.disconnect(true);
} }
} }
playerEntry.socket = newSocket; // Обновляем сокет в существующей playerEntry playerEntry.socket = newSocket;
this.players[newSocket.id] = playerEntry; // Убеждаемся, что по новому ID есть актуальная запись this.players[newSocket.id] = playerEntry; // Обновляем/добавляем запись с новым socket.id
// Если старый ID был ключом для playerEntry, и он не равен newSocket.id, удаляем старый ключ
if (oldSocket && oldSocket.id !== newSocket.id && this.players[oldSocket.id] === playerEntry) { if (oldSocket && oldSocket.id !== newSocket.id && this.players[oldSocket.id] === playerEntry) {
// Если вдруг playerEntry был взят по старому socket.id, и этот ID теперь должен быть удален
delete this.players[oldSocket.id]; delete this.players[oldSocket.id];
} }
this.playerSockets[playerIdRole] = newSocket; // Обновляем авторитетный сокет для роли this.playerSockets[playerIdRole] = newSocket;
// Всегда заново присоединяем сокет к комнате try {
console.log(`[PCH ${this.gameId}] Forcing newSocket ${newSocket.id} (identifier: ${identifier}) to join room ${this.gameId} during reconnect.`); newSocket.join(this.gameId);
newSocket.join(this.gameId); console.log(`[PCH ${this.gameId}] Сокет ${newSocket.id} (identifier: ${identifier}) присоединен/переприсоединен к комнате ${this.gameId} (handlePlayerReconnected).`);
} catch (e) {
console.error(`[PCH ${this.gameId}] КРИТИЧЕСКАЯ ОШИБКА при newSocket.join в handlePlayerReconnected: ${e.message}.`);
}
if (playerEntry.isTemporarilyDisconnected) { if (wasTemporarilyDisconnected) {
console.log(`[PCH ${this.gameId}] Переподключение игрока ${identifier} (Роль: ${playerIdRole}), который был временно отключен.`); console.log(`[PCH ${this.gameId}] Переподключение игрока ${identifier} (Роль: ${playerIdRole}), который был временно отключен.`);
this.clearReconnectTimer(playerIdRole); // Очищаем таймер реконнекта this.clearReconnectTimer(playerIdRole);
this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сообщаем UI, что таймер остановлен this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null });
playerEntry.isTemporarilyDisconnected = false; playerEntry.isTemporarilyDisconnected = false;
this.playerCount++; // Восстанавливаем счетчик активных игроков this.playerCount++;
} else { } else {
// Игрок не был помечен как временно отключенный. console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Старый сокет ID: ${oldSocket?.id}, Новый сокет ID: ${newSocket.id}`);
// Это может быть F5 или запрос состояния на "том же" (или новом, но старый не отвалился) сокете.
// playerCount не меняется, т.к. игрок считался активным.
console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Old socket ID: ${oldSocket?.id}`);
} }
// Обновление имени // Обновление имени
@ -288,28 +311,38 @@ class PlayerConnectionHandler {
this.gameInstance.addToLog(`🔌 Игрок ${playerEntry.name || identifier} снова в игре! (Сессия обновлена)`, GAME_CONFIG.LOG_TYPE_SYSTEM); this.gameInstance.addToLog(`🔌 Игрок ${playerEntry.name || identifier} снова в игре! (Сессия обновлена)`, GAME_CONFIG.LOG_TYPE_SYSTEM);
this.sendFullGameStateOnReconnect(newSocket, playerEntry, playerIdRole); this.sendFullGameStateOnReconnect(newSocket, playerEntry, playerIdRole);
if (playerEntry.isTemporarilyDisconnected === false && this.pausedTurnState) { // Если игрок был временно отключен, isTemporarilyDisconnected уже false // Логика возобновления игры/таймера
if (wasTemporarilyDisconnected && this.pausedTurnState) {
this.resumeGameLogicAfterReconnect(playerIdRole); this.resumeGameLogicAfterReconnect(playerIdRole);
} else if (playerEntry.isTemporarilyDisconnected === false && !this.pausedTurnState) { } else if (!wasTemporarilyDisconnected) {
// Игрок не был temp disconnected, и не было сохраненного состояния таймера (значит, он и не останавливался из-за этого игрока) // Игрок не был temp disconnected. Таймер на сервере, если шел, то продолжал идти.
// Просто отправляем текущее состояние таймера, если он активен // Клиент получил новое состояние. Нужно, чтобы он начал получать обновления таймера.
console.log(`[PCH ${this.gameId}] Player was not temp disconnected, and no pausedTurnState. Forcing timer update if active.`); // Принудительный join выше должен был помочь.
if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive() && this.gameInstance.turnTimer.onTickCallback) { // Дополнительно заставим таймер отправить текущее состояние.
console.log(`[PCH ${this.gameId}] Player was not temp disconnected. Forcing timer update if active (for socket ${newSocket.id}).`);
if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.onTickCallback) {
const tt = this.gameInstance.turnTimer; const tt = this.gameInstance.turnTimer;
const elapsedTime = Date.now() - tt.segmentStartTimeMs; // Если таймер реально работает (не ход AI и не на ручной паузе от другого игрока)
const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime); if (tt.isCurrentlyRunning && !tt.isManuallyPausedState && !tt.isConfiguredForAiMove) {
tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState); const elapsedTime = Date.now() - tt.segmentStartTimeMs;
} else if (this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused() && !this.isGameEffectivelyPaused()) { const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime);
// Если таймер не активен, не на паузе, и игра не на общей паузе - возможно, его нужно запустить (если сейчас ход этого игрока) console.log(`[PCH ${this.gameId}] Forcing onTickCallback. Remaining: ${currentRemaining}, ForPlayer: ${tt.isConfiguredForPlayerSlotTurn}, ManualPause: ${tt.isManuallyPausedState}`);
const gs = this.gameInstance.gameState; tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState);
if (gs && !gs.isGameOver) { } else if (tt.isConfiguredForAiMove && !tt.isCurrentlyRunning) { // Если ход AI
console.log(`[PCH ${this.gameId}] Forcing onTickCallback for AI move state.`);
tt.onTickCallback(tt.initialTurnDurationMs, tt.isConfiguredForPlayerSlotTurn, false);
} else if (tt.isManuallyPausedState) { // Если на ручной паузе (из-за другого игрока)
console.log(`[PCH ${this.gameId}] Forcing onTickCallback for manually paused state. Remaining: ${tt.segmentDurationMs}`);
tt.onTickCallback(tt.segmentDurationMs, tt.isConfiguredForPlayerSlotTurn, true);
} else if (!tt.isCurrentlyRunning && !tt.isManuallyPausedState && !this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) {
// Таймер не работает, не на паузе, игра не на паузе - возможно, его нужно запустить
const gs = this.gameInstance.gameState;
const isHisTurnNow = (gs.isPlayerTurn && playerIdRole === GAME_CONFIG.PLAYER_ID) || (!gs.isPlayerTurn && playerIdRole === GAME_CONFIG.OPPONENT_ID); const isHisTurnNow = (gs.isPlayerTurn && playerIdRole === GAME_CONFIG.PLAYER_ID) || (!gs.isPlayerTurn && playerIdRole === GAME_CONFIG.OPPONENT_ID);
const isAiTurnNow = this.mode === 'ai' && !gs.isPlayerTurn; const isAiTurnNow = this.mode === 'ai' && !gs.isPlayerTurn;
if(isHisTurnNow || isAiTurnNow) { if(isHisTurnNow || isAiTurnNow) {
console.log(`[PCH ${this.gameId}] Timer not active, not paused. Game not paused. Attempting to start timer for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`); console.log(`[PCH ${this.gameId}] Timer not active, attempting to start for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`);
this.gameInstance.turnTimer.start(gs.isPlayerTurn, isAiTurnNow); this.gameInstance.turnTimer.start(gs.isPlayerTurn, isAiTurnNow);
if (isAiTurnNow && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) { if (isAiTurnNow && !this.gameInstance.turnTimer.getIsConfiguredForAiMove?.()) {
// Доп. проверка, чтобы AI точно пошел, если это его ход и таймер не стартовал для него как "AI move"
setTimeout(() => { setTimeout(() => {
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) { if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) {
this.gameInstance.processAiTurn(); this.gameInstance.processAiTurn();
@ -322,11 +355,9 @@ class PlayerConnectionHandler {
} }
return true; return true;
} else { // playerEntry не найден } else {
console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена. Это может быть новый игрок или сессия истекла.`); console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена.`);
// Если это новый игрок для этой роли, то addPlayer должен был быть вызван GameManager'ом. newSocket.emit('gameError', { message: 'Не удалось найти вашу игровую сессию. Попробуйте создать игру заново.' });
// Если PCH вызывается напрямую, и игрока нет, это ошибка или устаревший запрос.
newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена). Попробуйте создать игру заново.' });
return false; return false;
} }
} }
@ -335,7 +366,7 @@ class PlayerConnectionHandler {
console.log(`[PCH SEND_STATE_RECONNECT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${playerEntry.identifier}`); console.log(`[PCH SEND_STATE_RECONNECT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${playerEntry.identifier}`);
if (!this.gameInstance.gameState) { if (!this.gameInstance.gameState) {
console.log(`[PCH SEND_STATE_RECONNECT] gameState отсутствует, попытка инициализации...`); console.log(`[PCH SEND_STATE_RECONNECT] gameState отсутствует, попытка инициализации...`);
if (!this.gameInstance.initializeGame()) { // initializeGame должен установить gameState if (!this.gameInstance.initializeGame()) {
this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch_helper', 'PCH Helper: GS null после повторной инициализации при переподключении.'); this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch_helper', 'PCH Helper: GS null после повторной инициализации при переподключении.');
return; return;
} }
@ -344,33 +375,25 @@ class PlayerConnectionHandler {
const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey);
const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID;
// Получаем ключ оппонента из gameState ИЛИ из сохраненных ключей в GameInstance
let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey || let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey ||
(playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey); (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey);
const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null;
// Обновляем имена в gameState на основе сохраненных в PCH или данных персонажей
if (this.gameInstance.gameState) { if (this.gameInstance.gameState) {
if (this.gameInstance.gameState[playerIdRole]) { if (this.gameInstance.gameState[playerIdRole]) {
this.gameInstance.gameState[playerIdRole].name = playerEntry.name || pData?.baseStats?.name || 'Игрок'; this.gameInstance.gameState[playerIdRole].name = playerEntry.name || pData?.baseStats?.name || 'Игрок';
} }
const opponentPCHEntry = Object.values(this.players).find(p => p.id === oppRoleKey); const opponentPCHEntry = Object.values(this.players).find(p => p.id === oppRoleKey);
if (this.gameInstance.gameState[oppRoleKey]) { if (this.gameInstance.gameState[oppRoleKey]) {
if (opponentPCHEntry?.name) { if (opponentPCHEntry?.name) this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name;
this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name; else if (oData?.baseStats?.name) this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name;
} else if (oData?.baseStats?.name) { else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) this.gameInstance.gameState[oppRoleKey].name = 'Балард';
this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name; else this.gameInstance.gameState[oppRoleKey].name = (this.mode === 'pvp' ? 'Ожидание Оппонента...' : 'Противник');
} else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) {
this.gameInstance.gameState[oppRoleKey].name = 'Балард'; // Фоллбэк для AI
} else {
this.gameInstance.gameState[oppRoleKey].name = 'Оппонент';
}
} }
} }
console.log(`[PCH SEND_STATE_RECONNECT] Отправка gameStarted. Player GS: ${this.gameInstance.gameState?.player?.name}, Opponent GS: ${this.gameInstance.gameState?.opponent?.name}. IsPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`); console.log(`[PCH SEND_STATE_RECONNECT] Отправка gameStarted. Player GS: ${this.gameInstance.gameState?.player?.name}, Opponent GS: ${this.gameInstance.gameState?.opponent?.name}. IsPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`);
socket.emit('gameStarted', { // Используем 'gameStarted' для полной синхронизации состояния socket.emit('gameStarted', {
gameId: this.gameId, gameId: this.gameId,
yourPlayerId: playerIdRole, yourPlayerId: playerIdRole,
initialGameState: this.gameInstance.gameState, initialGameState: this.gameInstance.gameState,
@ -378,7 +401,7 @@ class PlayerConnectionHandler {
opponentBaseStats: oData?.baseStats || {name: (this.mode === 'pvp' ? 'Ожидание...' : 'Противник AI'), maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null}, opponentBaseStats: oData?.baseStats || {name: (this.mode === 'pvp' ? 'Ожидание...' : 'Противник AI'), maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null},
playerAbilities: pData?.abilities, playerAbilities: pData?.abilities,
opponentAbilities: oData?.abilities || [], opponentAbilities: oData?.abilities || [],
log: this.gameInstance.consumeLogBuffer(), log: this.gameInstance.consumeLogBuffer(), // Отправляем все накопленные логи
clientConfig: { ...GAME_CONFIG } clientConfig: { ...GAME_CONFIG }
}); });
} }
@ -396,19 +419,15 @@ class PlayerConnectionHandler {
reconnectedPlayerId: reconnectedPlayerIdRole, reconnectedPlayerId: reconnectedPlayerIdRole,
reconnectedPlayerName: reconnectedName reconnectedPlayerName: reconnectedName
}); });
if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи другому игроку if (this.gameInstance.logBuffer.length > 0) {
otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() }); otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() });
} }
} }
// Обновляем состояние для всех (включая переподключившегося, т.к. его лог мог быть уже потреблен) this.gameInstance.broadcastGameStateUpdate(); // Обновляем состояние для всех
this.gameInstance.broadcastGameStateUpdate(); // Это отправит gameState и оставшиеся логи
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) { if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) {
// this.gameInstance.broadcastGameStateUpdate(); // Перенесено выше if (Object.keys(this.reconnectTimers).length === 0) {
if (Object.keys(this.reconnectTimers).length === 0) { // Только если нет других ожидающих реконнекта
const currentTurnIsForPlayerInGS = this.gameInstance.gameState.isPlayerTurn; const currentTurnIsForPlayerInGS = this.gameInstance.gameState.isPlayerTurn;
const isCurrentTurnAiForTimer = this.mode === 'ai' && !currentTurnIsForPlayerInGS; const isCurrentTurnAiForTimer = this.mode === 'ai' && !currentTurnIsForPlayerInGS;
let resumedFromPausedState = false; let resumedFromPausedState = false;
@ -421,20 +440,20 @@ class PlayerConnectionHandler {
console.log(`[PCH ${this.gameId}] Возобновляем таймер хода из pausedTurnState. Время: ${this.pausedTurnState.remainingTime}мс. Для игрока (в pausedState): ${this.pausedTurnState.forPlayerRoleIsPlayer}. GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход (в pausedState): ${this.pausedTurnState.isAiCurrentlyMoving}`); console.log(`[PCH ${this.gameId}] Возобновляем таймер хода из pausedTurnState. Время: ${this.pausedTurnState.remainingTime}мс. Для игрока (в pausedState): ${this.pausedTurnState.forPlayerRoleIsPlayer}. GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход (в pausedState): ${this.pausedTurnState.isAiCurrentlyMoving}`);
this.gameInstance.turnTimer.resume( this.gameInstance.turnTimer.resume(
this.pausedTurnState.remainingTime, this.pausedTurnState.remainingTime,
this.pausedTurnState.forPlayerRoleIsPlayer, // Это isConfiguredForPlayerSlotTurn для таймера this.pausedTurnState.forPlayerRoleIsPlayer,
this.pausedTurnState.isAiCurrentlyMoving // Это isConfiguredForAiMove для таймера this.pausedTurnState.isAiCurrentlyMoving
); );
resumedFromPausedState = true; resumedFromPausedState = true;
} else { } else {
console.warn(`[PCH ${this.gameId}] pausedTurnState (${JSON.stringify(this.pausedTurnState)}) не совпадает с текущим ходом в gameState (isPlayerTurn: ${currentTurnIsForPlayerInGS}). Сбрасываем pausedTurnState и запускаем таймер заново, если нужно.`); console.warn(`[PCH ${this.gameId}] pausedTurnState (${JSON.stringify(this.pausedTurnState)}) не совпадает с текущим ходом в gameState (isPlayerTurn: ${currentTurnIsForPlayerInGS}). Сбрасываем pausedTurnState и запускаем таймер заново, если нужно.`);
} }
this.pausedTurnState = null; // Сбрасываем в любом случае this.pausedTurnState = null;
} }
if (!resumedFromPausedState && this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused()) { if (!resumedFromPausedState && this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused()) {
console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался или был неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`); console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался/неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`);
this.gameInstance.turnTimer.start(currentTurnIsForPlayerInGS, isCurrentTurnAiForTimer); this.gameInstance.turnTimer.start(currentTurnIsForPlayerInGS, isCurrentTurnAiForTimer);
if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) { if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.getIsConfiguredForAiMove?.()) {
setTimeout(() => { setTimeout(() => {
if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) { if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) {
this.gameInstance.processAiTurn(); this.gameInstance.processAiTurn();
@ -454,13 +473,15 @@ class PlayerConnectionHandler {
clearReconnectTimer(playerIdRole) { clearReconnectTimer(playerIdRole) {
if (this.reconnectTimers[playerIdRole]) { if (this.reconnectTimers[playerIdRole]) {
clearTimeout(this.reconnectTimers[playerIdRole].timerId); if (this.reconnectTimers[playerIdRole].timerId) {
this.reconnectTimers[playerIdRole].timerId = null; // Явно обнуляем clearTimeout(this.reconnectTimers[playerIdRole].timerId);
this.reconnectTimers[playerIdRole].timerId = null;
}
if (this.reconnectTimers[playerIdRole].updateIntervalId) { if (this.reconnectTimers[playerIdRole].updateIntervalId) {
clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId);
this.reconnectTimers[playerIdRole].updateIntervalId = null; // Явно обнуляем this.reconnectTimers[playerIdRole].updateIntervalId = null;
} }
delete this.reconnectTimers[playerIdRole]; // Удаляем всю запись delete this.reconnectTimers[playerIdRole];
console.log(`[PCH ${this.gameId}] Очищен таймер переподключения для роли ${playerIdRole}.`); console.log(`[PCH ${this.gameId}] Очищен таймер переподключения для роли ${playerIdRole}.`);
} }
} }
@ -477,14 +498,13 @@ class PlayerConnectionHandler {
if (this.playerCount < 2 && Object.keys(this.players).length > 0) { if (this.playerCount < 2 && Object.keys(this.players).length > 0) {
const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID);
if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) { if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) {
return true; return true;
} }
} }
} else if (this.mode === 'ai') { } else if (this.mode === 'ai') {
const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID);
return humanPlayer?.isTemporarilyDisconnected ?? false; // Если игрока нет, не на паузе. Если есть - зависит от его состояния. return humanPlayer?.isTemporarilyDisconnected ?? false;
} }
return false; return false;
} }

View File

@ -1,25 +1,33 @@
// /server/game/instance/TurnTimer.js // /server/game/instance/TurnTimer.js
class TurnTimer { class TurnTimer {
/**
* Конструктор таймера хода.
* @param {number} turnDurationMs - Изначальная длительность хода в миллисекундах.
* @param {number} updateIntervalMs - Интервал для отправки обновлений времени клиентам (в мс).
* @param {function} onTimeoutCallback - Колбэк, вызываемый при истечении времени хода.
* @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic).
* @param {string} [gameIdForLogs=''] - (Опционально) ID игры для более понятных логов таймера.
*/
constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') { constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') {
this.initialTurnDurationMs = turnDurationMs; this.initialTurnDurationMs = turnDurationMs;
this.updateIntervalMs = updateIntervalMs; this.updateIntervalMs = updateIntervalMs;
this.onTimeoutCallback = onTimeoutCallback; this.onTimeoutCallback = onTimeoutCallback;
this.onTickCallback = onTickCallback; // (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic) this.onTickCallback = onTickCallback;
this.gameId = gameIdForLogs; this.gameId = gameIdForLogs;
this.timeoutId = null; this.timeoutId = null; // ID для setTimeout (обработка общего таймаута хода)
this.tickIntervalId = null; this.tickIntervalId = null; // ID для setInterval (периодическое обновление клиента)
this.segmentStartTimeMs = 0; // Время начала текущего активного сегмента (после start/resume) this.segmentStartTimeMs = 0; // Время (Date.now()) начала текущего активного сегмента (после start/resume)
this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий сегмент this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий активный сегмент
this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе, не ход AI) this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе из-за дисконнекта, не ход AI)
this.isManuallyPausedState = false; // Была ли вызвана pause() this.isManuallyPausedState = false; // Была ли вызвана pause() (например, из-за дисконнекта игрока)
// Состояние, для которого таймер был запущен (или должен быть запущен) // Состояние, для которого таймер был сконфигурирован при последнем запуске/возобновлении
this.isConfiguredForPlayerSlotTurn = false; this.isConfiguredForPlayerSlotTurn = false; // true, если таймер отсчитывает ход игрока (слот 'player')
this.isConfiguredForAiMove = false; this.isConfiguredForAiMove = false; // true, если это ход AI (таймер для реального игрока не тикает)
console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`); console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`);
} }
@ -28,10 +36,12 @@ class TurnTimer {
if (this.timeoutId) { if (this.timeoutId) {
clearTimeout(this.timeoutId); clearTimeout(this.timeoutId);
this.timeoutId = null; this.timeoutId = null;
// console.log(`[TurnTimer ${this.gameId}] Cleared timeoutId.`);
} }
if (this.tickIntervalId) { if (this.tickIntervalId) {
clearInterval(this.tickIntervalId); clearInterval(this.tickIntervalId);
this.tickIntervalId = null; this.tickIntervalId = null;
// console.log(`[TurnTimer ${this.gameId}] Cleared tickIntervalId.`);
} }
} }
@ -39,47 +49,62 @@ class TurnTimer {
* Запускает или перезапускает таймер хода. * Запускает или перезапускает таймер хода.
* @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'. * @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'.
* @param {boolean} isAiMakingMove - true, если текущий ход делает AI. * @param {boolean} isAiMakingMove - true, если текущий ход делает AI.
* @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени. * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени (обычно при resume).
*/ */
start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) { start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) {
console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, ManualPause: ${this.isManuallyPausedState}`); console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, CurrentManualPauseState: ${this.isManuallyPausedState}`);
this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском
this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn; this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn;
this.isConfiguredForAiMove = isAiMakingMove; this.isConfiguredForAiMove = isAiMakingMove;
// Если это не resume (т.е. customRemainingTimeMs не передан явно как результат pause), // Если start вызывается НЕ из resume (т.е. customRemainingTimeMs не передан как результат pause),
// то сбрасываем флаг ручной паузы. // то флаг ручной паузы должен быть сброшен.
// Если это вызов из resume, isManuallyPausedState уже был сброшен в resume перед вызовом start.
if (customRemainingTimeMs === null) { if (customRemainingTimeMs === null) {
this.isManuallyPausedState = false; this.isManuallyPausedState = false;
} }
if (this.isConfiguredForAiMove) { if (this.isConfiguredForAiMove) {
this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для игрока this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для UI игрока
console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking.`); this.segmentDurationMs = this.initialTurnDurationMs; // Для AI показываем полную длительность (или сколько он думает)
this.segmentStartTimeMs = Date.now(); // На всякий случай, хотя не используется для тиков AI
console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking. ManualPause: ${this.isManuallyPausedState}`);
if (this.onTickCallback) { if (this.onTickCallback) {
// Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе // Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе (т.к. игра идет)
this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false); this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false);
} }
return; return;
} }
// Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека) // Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека)
this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0) this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs >= 0) // Допускаем 0 для немедленного таймаута
? customRemainingTimeMs ? customRemainingTimeMs
: this.initialTurnDurationMs; : this.initialTurnDurationMs;
this.segmentStartTimeMs = Date.now(); this.segmentStartTimeMs = Date.now();
this.isCurrentlyRunning = true; // Таймер теперь активен this.isCurrentlyRunning = true; // Таймер теперь активен
// this.isManuallyPausedState остается как есть, если это был resume, или false, если это новый start
console.log(`[TurnTimer ${this.gameId}] STARTED. Effective Duration: ${this.segmentDurationMs}ms. ForPlayer: ${this.isConfiguredForPlayerSlotTurn}. IsRunning: ${this.isCurrentlyRunning}. ManualPause: ${this.isManuallyPausedState}`); console.log(`[TurnTimer ${this.gameId}] STARTED. Effective Duration: ${this.segmentDurationMs}ms. ForPlayer: ${this.isConfiguredForPlayerSlotTurn}. IsRunning: ${this.isCurrentlyRunning}. ManualPause: ${this.isManuallyPausedState}`);
if (this.segmentDurationMs <= 0) { // Если время 0 или меньше, сразу таймаут
console.log(`[TurnTimer ${this.gameId}] Start with 0 or less time, calling timeout immediately.`);
if (this.onTimeoutCallback) {
this.onTimeoutCallback();
}
this._clearInternalTimers();
this.isCurrentlyRunning = false;
// Отправляем финальный тик с 0 временем
if (this.onTickCallback) {
this.onTickCallback(0, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
}
return;
}
this.timeoutId = setTimeout(() => { this.timeoutId = setTimeout(() => {
console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
// Проверяем, что таймер все еще должен был работать и не был на паузе
if (this.isCurrentlyRunning && !this.isManuallyPausedState) { if (this.isCurrentlyRunning && !this.isManuallyPausedState) {
this._clearInternalTimers(); // Очищаем все, включая интервал this._clearInternalTimers();
this.isCurrentlyRunning = false; this.isCurrentlyRunning = false;
if (this.onTimeoutCallback) { if (this.onTimeoutCallback) {
this.onTimeoutCallback(); this.onTimeoutCallback();
@ -90,10 +115,17 @@ class TurnTimer {
}, this.segmentDurationMs); }, this.segmentDurationMs);
this.tickIntervalId = setInterval(() => { this.tickIntervalId = setInterval(() => {
// Таймер должен обновлять UI только если он isCurrentlyRunning и НЕ isManuallyPausedState if (!this.isCurrentlyRunning || this.isManuallyPausedState) {
// isManuallyPausedState проверяется в onTickCallback, который должен передать "isPaused" клиенту // Если таймер остановлен или на ручной паузе, интервал не должен ничего делать, кроме как, возможно,
if (!this.isCurrentlyRunning) { // Если таймер был остановлен (clear/timeout) // сообщить, что он на паузе. Но лучше, чтобы onTickCallback вызывался с флагом паузы.
this._clearInternalTimers(); // Убедимся, что этот интервал тоже остановлен // Если он был остановлен (isCurrentlyRunning=false, но не isManuallyPausedState),
// то clear() должен был уже остановить и этот интервал.
// Эта проверка - дополнительная защита.
// console.log(`[TurnTimer ${this.gameId}] Tick interval fired but timer not running or manually paused. Running: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
if (!this.isCurrentlyRunning && this.tickIntervalId) { // Если совсем остановлен, чистим себя
clearInterval(this.tickIntervalId);
this.tickIntervalId = null;
}
return; return;
} }
@ -101,13 +133,16 @@ class TurnTimer {
const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime); const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime);
if (this.onTickCallback) { if (this.onTickCallback) {
// Передаем isManuallyPausedState как состояние "паузы" для клиента // Передаем isManuallyPausedState как состояние "паузы" для клиента,
// но здесь оно всегда false, т.к. есть проверка `!this.isManuallyPausedState` выше.
// Более корректно передавать `this.isManuallyPausedState || !this.isCurrentlyRunning` как общую паузу с точки зрения таймера.
// Но PCH передает `isPaused || this.isGameEffectivelyPaused()`.
// Для `onTickCallback` здесь, isPaused будет отражать `this.isManuallyPausedState`.
this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
} }
// Не очищаем интервал здесь при remainingTime <= 0, пусть setTimeout это сделает.
// Отправка 0 - это нормально.
}, this.updateIntervalMs); }, this.updateIntervalMs);
console.log(`[TurnTimer ${this.gameId}] Tick interval started: ${this.tickIntervalId}.`);
// Немедленная первая отправка состояния таймера // Немедленная первая отправка состояния таймера
if (this.onTickCallback) { if (this.onTickCallback) {
@ -117,121 +152,123 @@ class TurnTimer {
} }
pause() { pause() {
console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, ManualPause: ${this.isManuallyPausedState}`); console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, CurrentManualPauseState: ${this.isManuallyPausedState}`);
if (this.isManuallyPausedState) { // Уже на ручной паузе if (this.isManuallyPausedState) {
console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Returning previous pause state.`); console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Current saved duration (remaining): ${this.segmentDurationMs}`);
// Нужно вернуть актуальное оставшееся время, которое было на момент установки паузы. if (this.onTickCallback) {
// segmentDurationMs при паузе сохраняет это значение.
if (this.onTickCallback) { // Уведомляем клиента еще раз, что на паузе
this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true); this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true);
} }
return { return {
remainingTime: this.segmentDurationMs, // Это время, которое осталось на момент паузы remainingTime: this.segmentDurationMs,
forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn,
isAiCurrentlyMoving: this.isConfiguredForAiMove // Важно сохранить, чей ход это был isAiCurrentlyMoving: this.isConfiguredForAiMove
}; };
} }
let remainingTimeToSave; let remainingTimeToSaveOnPause;
if (this.isConfiguredForAiMove) { if (this.isConfiguredForAiMove) {
// Если ход AI, таймер для игрока не тикал, у него полное время remainingTimeToSaveOnPause = this.initialTurnDurationMs; // Для AI всегда полное время (или как настроено)
remainingTimeToSave = this.initialTurnDurationMs; console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining for player: ${remainingTimeToSaveOnPause}ms.`);
console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining: ${remainingTimeToSave}ms for player turn.`);
} else if (this.isCurrentlyRunning) { } else if (this.isCurrentlyRunning) {
// Таймер активно работал для игрока/оппонента-человека
const elapsedTime = Date.now() - this.segmentStartTimeMs; const elapsedTime = Date.now() - this.segmentStartTimeMs;
remainingTimeToSave = Math.max(0, this.segmentDurationMs - elapsedTime); remainingTimeToSaveOnPause = Math.max(0, this.segmentDurationMs - elapsedTime);
console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSave}ms from segment duration ${this.segmentDurationMs}ms.`); console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSaveOnPause}ms from segment duration ${this.segmentDurationMs}ms.`);
} else { } else {
// Таймер не был активен (например, уже истек, был очищен, или это был start() для AI) // Таймер не был активен (и не ход AI). Значит, время 0.
// В этом случае, если не ход AI, то время 0 remainingTimeToSaveOnPause = 0;
remainingTimeToSave = 0;
console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`); console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`);
} }
this._clearInternalTimers(); this._clearInternalTimers();
this.isCurrentlyRunning = false; this.isCurrentlyRunning = false; // Отсчет остановлен
this.isManuallyPausedState = true; this.isManuallyPausedState = true; // Устанавливаем флаг ручной паузы
this.segmentDurationMs = remainingTimeToSave; // Сохраняем оставшееся время для resume this.segmentDurationMs = remainingTimeToSaveOnPause; // Сохраняем оставшееся время в segmentDurationMs для resume
if (this.onTickCallback) { if (this.onTickCallback) {
console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE. Remaining: ${remainingTimeToSave}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`); console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE state. Remaining: ${remainingTimeToSaveOnPause}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`);
this.onTickCallback(remainingTimeToSave, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true this.onTickCallback(remainingTimeToSaveOnPause, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true
} }
return { return {
remainingTime: remainingTimeToSave, remainingTime: remainingTimeToSaveOnPause,
forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, // Чей ход это был forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn,
isAiCurrentlyMoving: this.isConfiguredForAiMove // Был ли это ход AI isAiCurrentlyMoving: this.isConfiguredForAiMove
}; };
} }
resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) { resume(remainingTimeMsFromPause, forPlayerSlotTurn, isAiMakingMove) {
console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. SavedRemaining: ${remainingTimeMs}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, ManualPauseBefore: ${this.isManuallyPausedState}`); console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. TimeFromPause: ${remainingTimeMsFromPause}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CurrentManualPauseState: ${this.isManuallyPausedState}`);
if (!this.isManuallyPausedState) { if (!this.isManuallyPausedState) {
console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. Current state - IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}. Ignoring resume, let PCH handle start if needed.`); console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. This might indicate a logic issue elsewhere or a stale resume attempt. Ignoring.`);
// Если не был на ручной паузе, возможно, игра уже продолжается или была очищена. // Если таймер не был на ручной паузе, то он либо работает, либо уже остановлен по другой причине.
// Не вызываем start() отсюда, чтобы избежать неожиданного поведения. // Не вызываем start() отсюда, чтобы PCH мог принять решение о новом старте, если это необходимо.
// PCH должен решить, нужен ли новый start(). // Можно отправить текущее состояние, если он работает, для синхронизации.
// Однако, если текущий ход совпадает, и таймер просто неактивен, можно запустить. if (this.isCurrentlyRunning && this.onTickCallback) {
// Но лучше, чтобы PCH всегда вызывал start() с нуля, если resume не применим. const elapsedTime = Date.now() - this.segmentStartTimeMs;
// Просто отправим текущее состояние, если onTickCallback есть. const currentRemaining = Math.max(0, this.segmentDurationMs - elapsedTime);
if (this.onTickCallback) { console.log(`[TurnTimer ${this.gameId}] Resume ignored (not manually paused), sending current state if running. Remaining: ${currentRemaining}`);
const currentElapsedTime = this.isCurrentlyRunning ? (Date.now() - this.segmentStartTimeMs) : 0; this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, false);
const currentRemaining = this.isCurrentlyRunning ? Math.max(0, this.segmentDurationMs - currentElapsedTime) : this.segmentDurationMs;
this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState);
} }
return; return;
} }
if (remainingTimeMs <= 0 && !isAiMakingMove) { // Если не ход AI и время вышло // Сбрасываем флаг ручной паузы ПЕРЕД вызовом start
this.isManuallyPausedState = false;
if (remainingTimeMsFromPause <= 0 && !isAiMakingMove) {
console.log(`[TurnTimer ${this.gameId}] RESUME called with 0 or less time (and not AI move). Triggering timeout.`); console.log(`[TurnTimer ${this.gameId}] RESUME called with 0 or less time (and not AI move). Triggering timeout.`);
this.isManuallyPausedState = false; // Сбрасываем флаг this._clearInternalTimers();
this._clearInternalTimers(); // Убедимся, что все остановлено
this.isCurrentlyRunning = false; this.isCurrentlyRunning = false;
if (this.onTimeoutCallback) { if (this.onTimeoutCallback) {
this.onTimeoutCallback(); this.onTimeoutCallback();
} }
// Отправляем финальный тик с 0 временем и снятой паузой
if (this.onTickCallback) {
this.onTickCallback(0, forPlayerSlotTurn, false);
}
return; return;
} }
// Сбрасываем флаг ручной паузы и запускаем таймер с сохраненным состоянием // Запускаем таймер с сохраненным состоянием и оставшимся временем
this.isManuallyPausedState = false; // `start` сама установит isCurrentlyRunning и другие флаги.
this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); // `start` теперь правильно обработает customRemainingTimeMs this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMsFromPause);
} }
/**
* Очищает (останавливает) все активные таймеры и сбрасывает состояние.
* Вызывается при завершении действия, таймауте, или если игра заканчивается.
*/
clear() { clear() {
console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`);
this._clearInternalTimers(); this._clearInternalTimers();
this.isCurrentlyRunning = false; this.isCurrentlyRunning = false;
// При полном clear сбрасываем и ручную паузу, т.к. таймер полностью останавливается. this.isManuallyPausedState = false; // Полная очистка сбрасывает и ручную паузу
// `pause` использует этот метод, но затем сам выставляет isManuallyPausedState = true. // this.segmentDurationMs = 0; // Можно сбросить, но start() все равно установит новое
this.isManuallyPausedState = false; // this.segmentStartTimeMs = 0;
this.segmentDurationMs = 0; // Сбрасываем сохраненную длительность
this.segmentStartTimeMs = 0;
// Опционально: уведомить клиента, что таймер остановлен (например, null или 0) // При clear не отправляем tickCallback, т.к. это означает конец отсчета для текущего хода.
// if (this.onTickCallback) { // Клиентский UI должен будет обновиться следующим gameStateUpdate или gameStarted.
// this.onTickCallback(null, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true (т.к. он остановлен)
// }
} }
isActive() { isActive() {
// Таймер активен, если он isCurrentlyRunning и не на ручной паузе // Активен, если запущен И не на ручной паузе И не ход AI (для которого таймер игрока не тикает)
return this.isCurrentlyRunning && !this.isManuallyPausedState; return this.isCurrentlyRunning && !this.isManuallyPausedState && !this.isConfiguredForAiMove;
} }
isPaused() { // Возвращает, находится ли таймер в состоянии ручной паузы isPaused() {
// Возвращает, находится ли таймер в состоянии ручной паузы (вызванной извне)
return this.isManuallyPausedState; return this.isManuallyPausedState;
} }
// Этот геттер больше не нужен в таком виде, т.к. isConfiguredForAiMove хранит это состояние // Геттер для PCH, чтобы знать, сконфигурирован ли таймер для хода AI.
// get isAiCurrentlyMakingMove() { // Это не означает, что AI *прямо сейчас* делает вычисления, а лишь то,
// return this.isConfiguredForAiMove && !this.isCurrentlyRunning; // что таймер был запущен для состояния "ход AI".
// } getIsConfiguredForAiMove() {
return this.isConfiguredForAiMove;
}
} }
module.exports = TurnTimer; module.exports = TurnTimer;