// /server/game/instance/TurnTimer.js 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 = '') { this.initialTurnDurationMs = turnDurationMs; this.updateIntervalMs = updateIntervalMs; this.onTimeoutCallback = onTimeoutCallback; this.onTickCallback = onTickCallback; this.gameId = gameIdForLogs; this.timeoutId = null; // ID для setTimeout (обработка общего таймаута хода) this.tickIntervalId = null; // ID для setInterval (периодическое обновление клиента) this.segmentStartTimeMs = 0; // Время (Date.now()) начала текущего активного сегмента (после start/resume) this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий активный сегмент this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе из-за дисконнекта, не ход AI) this.isManuallyPausedState = false; // Была ли вызвана pause() (например, из-за дисконнекта игрока) // Состояние, для которого таймер был сконфигурирован при последнем запуске/возобновлении this.isConfiguredForPlayerSlotTurn = false; // true, если таймер отсчитывает ход игрока (слот 'player') this.isConfiguredForAiMove = false; // true, если это ход AI (таймер для реального игрока не тикает) console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`); } _clearInternalTimers() { if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; // console.log(`[TurnTimer ${this.gameId}] Cleared timeoutId.`); } if (this.tickIntervalId) { clearInterval(this.tickIntervalId); this.tickIntervalId = null; // console.log(`[TurnTimer ${this.gameId}] Cleared tickIntervalId.`); } } /** * Запускает или перезапускает таймер хода. * @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'. * @param {boolean} isAiMakingMove - true, если текущий ход делает AI. * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени (обычно при resume). */ start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) { console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, CurrentManualPauseState: ${this.isManuallyPausedState}`); this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn; this.isConfiguredForAiMove = isAiMakingMove; // Если start вызывается НЕ из resume (т.е. customRemainingTimeMs не передан как результат pause), // то флаг ручной паузы должен быть сброшен. // Если это вызов из resume, isManuallyPausedState уже был сброшен в resume перед вызовом start. if (customRemainingTimeMs === null) { this.isManuallyPausedState = false; } if (this.isConfiguredForAiMove) { this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для UI игрока 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) { // Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе (т.к. игра идет) this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false); } return; } // Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека) this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs >= 0) // Допускаем 0 для немедленного таймаута ? customRemainingTimeMs : this.initialTurnDurationMs; this.segmentStartTimeMs = Date.now(); this.isCurrentlyRunning = true; // Таймер теперь активен 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(() => { console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); if (this.isCurrentlyRunning && !this.isManuallyPausedState) { this._clearInternalTimers(); this.isCurrentlyRunning = false; if (this.onTimeoutCallback) { this.onTimeoutCallback(); } } else { console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT ignored (not running or manually paused).`); } }, this.segmentDurationMs); this.tickIntervalId = setInterval(() => { if (!this.isCurrentlyRunning || this.isManuallyPausedState) { // Если таймер остановлен или на ручной паузе, интервал не должен ничего делать, кроме как, возможно, // сообщить, что он на паузе. Но лучше, чтобы onTickCallback вызывался с флагом паузы. // Если он был остановлен (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; } const elapsedTime = Date.now() - this.segmentStartTimeMs; const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime); if (this.onTickCallback) { // Передаем isManuallyPausedState как состояние "паузы" для клиента, // но здесь оно всегда false, т.к. есть проверка `!this.isManuallyPausedState` выше. // Более корректно передавать `this.isManuallyPausedState || !this.isCurrentlyRunning` как общую паузу с точки зрения таймера. // Но PCH передает `isPaused || this.isGameEffectivelyPaused()`. // Для `onTickCallback` здесь, isPaused будет отражать `this.isManuallyPausedState`. this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); } }, this.updateIntervalMs); console.log(`[TurnTimer ${this.gameId}] Tick interval started: ${this.tickIntervalId}.`); // Немедленная первая отправка состояния таймера if (this.onTickCallback) { console.log(`[TurnTimer ${this.gameId}] Initial tick after START. Remaining: ${this.segmentDurationMs}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}, ManualPause: ${this.isManuallyPausedState}`); this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); } } pause() { console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, CurrentManualPauseState: ${this.isManuallyPausedState}`); if (this.isManuallyPausedState) { console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Current saved duration (remaining): ${this.segmentDurationMs}`); if (this.onTickCallback) { this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true); } return { remainingTime: this.segmentDurationMs, forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, isAiCurrentlyMoving: this.isConfiguredForAiMove }; } let remainingTimeToSaveOnPause; if (this.isConfiguredForAiMove) { remainingTimeToSaveOnPause = this.initialTurnDurationMs; // Для AI всегда полное время (или как настроено) console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining for player: ${remainingTimeToSaveOnPause}ms.`); } else if (this.isCurrentlyRunning) { const elapsedTime = Date.now() - this.segmentStartTimeMs; remainingTimeToSaveOnPause = Math.max(0, this.segmentDurationMs - elapsedTime); console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSaveOnPause}ms from segment duration ${this.segmentDurationMs}ms.`); } else { // Таймер не был активен (и не ход AI). Значит, время 0. remainingTimeToSaveOnPause = 0; console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`); } this._clearInternalTimers(); this.isCurrentlyRunning = false; // Отсчет остановлен this.isManuallyPausedState = true; // Устанавливаем флаг ручной паузы this.segmentDurationMs = remainingTimeToSaveOnPause; // Сохраняем оставшееся время в segmentDurationMs для resume if (this.onTickCallback) { console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE state. Remaining: ${remainingTimeToSaveOnPause}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`); this.onTickCallback(remainingTimeToSaveOnPause, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true } return { remainingTime: remainingTimeToSaveOnPause, forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, isAiCurrentlyMoving: this.isConfiguredForAiMove }; } resume(remainingTimeMsFromPause, forPlayerSlotTurn, isAiMakingMove) { console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. TimeFromPause: ${remainingTimeMsFromPause}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CurrentManualPauseState: ${this.isManuallyPausedState}`); if (!this.isManuallyPausedState) { 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() отсюда, чтобы PCH мог принять решение о новом старте, если это необходимо. // Можно отправить текущее состояние, если он работает, для синхронизации. if (this.isCurrentlyRunning && this.onTickCallback) { const elapsedTime = Date.now() - this.segmentStartTimeMs; const currentRemaining = Math.max(0, this.segmentDurationMs - elapsedTime); console.log(`[TurnTimer ${this.gameId}] Resume ignored (not manually paused), sending current state if running. Remaining: ${currentRemaining}`); this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, false); } return; } // Сбрасываем флаг ручной паузы ПЕРЕД вызовом 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.`); this._clearInternalTimers(); this.isCurrentlyRunning = false; if (this.onTimeoutCallback) { this.onTimeoutCallback(); } // Отправляем финальный тик с 0 временем и снятой паузой if (this.onTickCallback) { this.onTickCallback(0, forPlayerSlotTurn, false); } return; } // Запускаем таймер с сохраненным состоянием и оставшимся временем // `start` сама установит isCurrentlyRunning и другие флаги. this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMsFromPause); } /** * Очищает (останавливает) все активные таймеры и сбрасывает состояние. * Вызывается при завершении действия, таймауте, или если игра заканчивается. */ clear() { console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); this._clearInternalTimers(); this.isCurrentlyRunning = false; this.isManuallyPausedState = false; // Полная очистка сбрасывает и ручную паузу // this.segmentDurationMs = 0; // Можно сбросить, но start() все равно установит новое // this.segmentStartTimeMs = 0; // При clear не отправляем tickCallback, т.к. это означает конец отсчета для текущего хода. // Клиентский UI должен будет обновиться следующим gameStateUpdate или gameStarted. } isActive() { // Активен, если запущен И не на ручной паузе И не ход AI (для которого таймер игрока не тикает) return this.isCurrentlyRunning && !this.isManuallyPausedState && !this.isConfiguredForAiMove; } isPaused() { // Возвращает, находится ли таймер в состоянии ручной паузы (вызванной извне) return this.isManuallyPausedState; } // Геттер для PCH, чтобы знать, сконфигурирован ли таймер для хода AI. // Это не означает, что AI *прямо сейчас* делает вычисления, а лишь то, // что таймер был запущен для состояния "ход AI". getIsConfiguredForAiMove() { return this.isConfiguredForAiMove; } } module.exports = TurnTimer;