bc/server/game/GameManager.js
2025-05-25 17:47:38 +03:00

439 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /server/game/GameManager.js
const { v4: uuidv4 } = require('uuid');
const GameInstance = require('./instance/GameInstance');
const dataUtils = require('../data/dataUtils');
const GAME_CONFIG = require('../core/config');
class GameManager {
constructor(io) {
this.io = io;
this.games = {}; // { gameId: GameInstance }
this.userIdentifierToGameId = {}; // { userId: gameId }
this.pendingPvPGames = []; // Массив gameId ожидающих PvP игр
console.log("[GameManager] Initialized.");
}
_removePreviousPendingGames(currentSocketId, identifier, excludeGameId = null) {
console.log(`[GameManager._removePreviousPendingGames] User: ${identifier}, Socket: ${currentSocketId}, Exclude: ${excludeGameId}`);
const oldPendingGameId = this.userIdentifierToGameId[identifier];
if (oldPendingGameId && oldPendingGameId !== excludeGameId && this.games[oldPendingGameId]) {
const gameToRemove = this.games[oldPendingGameId];
// Используем game.playerCount (или аналогичный метод GameInstance, если он инкапсулирует это)
if (gameToRemove.mode === 'pvp' &&
gameToRemove.playerCount === 1 && // Предполагаем, GameInstance.playerCount - это активные игроки
gameToRemove.ownerIdentifier === identifier &&
this.pendingPvPGames.includes(oldPendingGameId)) {
console.log(`[GameManager._removePreviousPendingGames] User ${identifier} creating/joining new. Removing previous pending PvP game: ${oldPendingGameId}`);
this._cleanupGame(oldPendingGameId, 'owner_action_removed_pending_game');
}
}
}
createGame(socket, mode = 'ai', chosenCharacterKey = null, identifier) {
console.log(`[GameManager.createGame] User: ${identifier} (Socket: ${socket.id}), Mode: ${mode}, Char: ${chosenCharacterKey || 'Default'}`);
const existingGameId = this.userIdentifierToGameId[identifier];
if (existingGameId && this.games[existingGameId]) {
const existingGame = this.games[existingGameId];
// Используем game.playerCount
console.warn(`[GameManager.createGame] User ${identifier} already in game ${existingGameId}. Mode: ${existingGame.mode}, Players: ${existingGame.playerCount}, Owner: ${existingGame.ownerIdentifier}, GameOver: ${existingGame.gameState?.isGameOver}`);
if (existingGame.gameState && !existingGame.gameState.isGameOver) {
if (existingGame.mode === 'pvp' && existingGame.playerCount === 1 && existingGame.ownerIdentifier === identifier) {
socket.emit('gameError', { message: 'Вы уже создали PvP игру и ожидаете оппонента.' });
} else {
socket.emit('gameError', { message: 'Вы уже находитесь в активной игре.' });
}
this.handleRequestGameState(socket, identifier);
return;
} else {
this._cleanupGame(existingGameId, `stale_finished_on_create_${identifier}`);
}
}
this._removePreviousPendingGames(socket.id, identifier);
const gameId = uuidv4();
console.log(`[GameManager.createGame] New GameID: ${gameId}`);
const game = new GameInstance(gameId, this.io, mode, this);
this.games[gameId] = game;
const charKeyForPlayer = mode === 'ai' ? (chosenCharacterKey || 'elena') : (chosenCharacterKey || 'elena');
// addPlayer в GameInstance теперь bool, а не объект с результатом
if (game.addPlayer(socket, charKeyForPlayer, identifier)) {
this.userIdentifierToGameId[identifier] = gameId;
// Получаем роль и актуальный ключ из GameInstance после добавления
const playerInfo = Object.values(game.players).find(p => p.identifier === identifier);
const assignedPlayerId = playerInfo?.id;
const actualCharacterKey = playerInfo?.chosenCharacterKey;
if (!assignedPlayerId || !actualCharacterKey) {
console.error(`[GameManager.createGame] CRITICAL: Failed to get player role/charKey after addPlayer for ${identifier} in game ${gameId}. Cleaning up.`);
this._cleanupGame(gameId, 'player_info_missing_after_add');
socket.emit('gameError', { message: 'Ошибка сервера при создании роли в игре.' });
return;
}
console.log(`[GameManager.createGame] Player ${identifier} added to game ${gameId} as ${assignedPlayerId}. User map updated.`);
socket.emit('gameCreated', {
gameId: gameId,
mode: mode,
yourPlayerId: assignedPlayerId,
chosenCharacterKey: actualCharacterKey
});
if (mode === 'ai') {
if (game.initializeGame()) {
game.startGame();
} else {
this._cleanupGame(gameId, 'init_fail_ai_create_gm');
}
} else if (mode === 'pvp') {
game.initializeGame(); // Инициализирует первого игрока
if (!this.pendingPvPGames.includes(gameId)) {
this.pendingPvPGames.push(gameId);
}
socket.emit('waitingForOpponent');
this.broadcastAvailablePvPGames();
}
} else {
console.error(`[GameManager.createGame] game.addPlayer (instance method) failed for ${identifier} in ${gameId}. Cleaning up.`);
this._cleanupGame(gameId, 'player_add_failed_in_instance_gm');
}
}
joinGame(socket, gameIdToJoin, identifier, chosenCharacterKey = null) {
console.log(`[GameManager.joinGame] User: ${identifier} (Socket: ${socket.id}) attempts to join ${gameIdToJoin} with char ${chosenCharacterKey || 'Default'}`);
const game = this.games[gameIdToJoin];
if (!game) { socket.emit('gameError', { message: 'Игра с таким ID не найдена.' }); return; }
if (game.gameState?.isGameOver) { socket.emit('gameError', { message: 'Эта игра уже завершена.' }); this._cleanupGame(gameIdToJoin, `attempt_join_finished_${identifier}`); return; }
if (game.mode !== 'pvp') { socket.emit('gameError', { message: 'К этой игре нельзя присоединиться (не PvP).' }); return; }
const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier);
if (game.playerCount >= 2 && !playerInfoInGame?.isTemporarilyDisconnected) {
socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return;
}
if (game.ownerIdentifier === identifier && !playerInfoInGame?.isTemporarilyDisconnected) {
socket.emit('gameError', { message: 'Вы не можете присоединиться к своей же ожидающей игре как новый игрок.' }); this.handleRequestGameState(socket, identifier); return;
}
const existingGameIdOfUser = this.userIdentifierToGameId[identifier];
if (existingGameIdOfUser && existingGameIdOfUser !== gameIdToJoin) {
const otherGame = this.games[existingGameIdOfUser];
if (otherGame && !otherGame.gameState?.isGameOver) { socket.emit('gameError', { message: 'Вы уже в другой активной игре.' }); this.handleRequestGameState(socket, identifier); return; }
else if (otherGame?.gameState?.isGameOver) this._cleanupGame(existingGameIdOfUser, `stale_finished_on_join_${identifier}`);
}
this._removePreviousPendingGames(socket.id, identifier, gameIdToJoin);
const charKeyForJoin = chosenCharacterKey || 'elena';
if (game.addPlayer(socket, charKeyForJoin, identifier)) {
this.userIdentifierToGameId[identifier] = gameIdToJoin;
const joinedPlayerInfo = Object.values(game.players).find(p => p.identifier === identifier); // Получаем инфо после добавления
if (!joinedPlayerInfo || !joinedPlayerInfo.id || !joinedPlayerInfo.chosenCharacterKey) {
console.error(`[GameManager.joinGame] CRITICAL: Failed to get player role/charKey after addPlayer for ${identifier} joining ${gameIdToJoin}. Cleaning up.`);
// Не вызываем _cleanupGame здесь, т.к. игра могла быть уже с одним игроком.
// GameInstance.addPlayer должен был бы вернуть false и не изменить playerCount.
socket.emit('gameError', { message: 'Ошибка сервера при назначении роли в игре.' });
return;
}
console.log(`[GameManager.joinGame] Player ${identifier} added/reconnected to ${gameIdToJoin} as ${joinedPlayerInfo.id}.`);
socket.emit('gameCreated', {
gameId: gameIdToJoin,
mode: game.mode,
yourPlayerId: joinedPlayerInfo.id,
chosenCharacterKey: joinedPlayerInfo.chosenCharacterKey
});
if (game.playerCount === 2) { // Используем game.playerCount из GameInstance
console.log(`[GameManager.joinGame] Game ${gameIdToJoin} is now full. Initializing and starting.`);
if (game.initializeGame()) {
game.startGame();
} else {
this._cleanupGame(gameIdToJoin, 'full_init_fail_pvp_join_gm'); return;
}
const idx = this.pendingPvPGames.indexOf(gameIdToJoin);
if (idx > -1) this.pendingPvPGames.splice(idx, 1);
this.broadcastAvailablePvPGames();
}
}
}
findAndJoinRandomPvPGame(socket, chosenCharacterKeyForCreation = 'elena', identifier) {
// ... (Логика findAndJoinRandomPvPGame без изменений, использует game.playerCount)
console.log(`[GameManager.findRandomPvPGame] User: ${identifier} (Socket: ${socket.id}), CharForCreation: ${chosenCharacterKeyForCreation}`);
const existingGameId = this.userIdentifierToGameId[identifier];
if (existingGameId && this.games[existingGameId]) {
const existingGame = this.games[existingGameId];
if (!existingGame.gameState?.isGameOver) {
socket.emit('gameError', { message: 'Вы уже в активной или ожидающей игре.' });
this.handleRequestGameState(socket, identifier); return;
} else {
this._cleanupGame(existingGameId, `stale_finished_on_find_random_${identifier}`);
}
}
this._removePreviousPendingGames(socket.id, identifier);
let gameIdToJoin = null;
for (const id of [...this.pendingPvPGames]) {
const pendingGame = this.games[id];
if (pendingGame && pendingGame.mode === 'pvp' &&
pendingGame.playerCount === 1 &&
pendingGame.ownerIdentifier !== identifier &&
!pendingGame.gameState?.isGameOver) {
gameIdToJoin = id; break;
} else if (pendingGame?.gameState?.isGameOver) {
this._cleanupGame(id, `stale_finished_pending_on_find_random`);
}
}
if (gameIdToJoin) {
console.log(`[GameManager.findRandomPvPGame] Found pending game ${gameIdToJoin} for ${identifier}. Joining...`);
const randomJoinCharKey = ['elena', 'almagest', 'balard'][Math.floor(Math.random() * 3)];
this.joinGame(socket, gameIdToJoin, identifier, randomJoinCharKey);
} else {
console.log(`[GameManager.findRandomPvPGame] No suitable pending game. Creating new PvP game for ${identifier}.`);
this.createGame(socket, 'pvp', chosenCharacterKeyForCreation, identifier);
}
}
handlePlayerAction(identifier, actionData) {
const gameId = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handlePlayerAction] User: ${identifier}, Action: ${actionData?.actionType}, GameID: ${gameId}`);
const game = this.games[gameId];
if (game) {
if (game.gameState?.isGameOver) {
const playerSocket = Object.values(game.players).find(p => p.identifier === identifier)?.socket; // Находим сокет по identifier
if (playerSocket) this.handleRequestGameState(playerSocket, identifier);
return;
}
game.processPlayerAction(identifier, actionData); // Передаем identifier
} else {
delete this.userIdentifierToGameId[identifier];
const clientSocket = this._findClientSocketByIdentifier(identifier);
if (clientSocket) clientSocket.emit('gameNotFound', { message: 'Ваша игровая сессия не найдена при действии.' });
}
}
handlePlayerSurrender(identifier) {
// ... (Логика handlePlayerSurrender без изменений)
const gameId = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handlePlayerSurrender] User: ${identifier} surrendered. GameID from map: ${gameId}`);
const game = this.games[gameId];
if (game) {
if (game.gameState?.isGameOver) {
console.warn(`[GameManager.handlePlayerSurrender] User ${identifier} in game ${gameId} surrender, but game ALREADY OVER.`);
if (this.userIdentifierToGameId[identifier] === gameId) delete this.userIdentifierToGameId[identifier];
return;
}
if (typeof game.playerDidSurrender === 'function') game.playerDidSurrender(identifier);
else { console.error(`[GameManager.handlePlayerSurrender] CRITICAL: GameInstance ${gameId} missing playerDidSurrender!`); this._cleanupGame(gameId, "surrender_missing_method"); }
} else {
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
}
}
handleLeaveAiGame(identifier) {
const gameId = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handleLeaveAiGame] User: ${identifier} leaving AI game. GameID from map: ${gameId}`);
const game = this.games[gameId];
if (game) {
if (game.gameState?.isGameOver) {
console.warn(`[GameManager.handleLeaveAiGame] User ${identifier} game ${gameId} leaving, but game ALREADY OVER.`);
return;
}
if (game.mode === 'ai') {
if (typeof game.playerExplicitlyLeftAiGame === 'function') {
game.playerExplicitlyLeftAiGame(identifier);
} else {
console.error(`[GameManager.handleLeaveAiGame] CRITICAL: GameInstance ${gameId} missing playerExplicitlyLeftAiGame! Cleaning up directly.`);
this._cleanupGame(gameId, "leave_ai_missing_method");
}
} else {
console.warn(`[GameManager.handleLeaveAiGame] User ${identifier} sent leaveAiGame, but game ${gameId} is not AI mode (${game.mode}).`);
}
} else {
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
}
}
_findClientSocketByIdentifier(identifier) {
// ... (код без изменений)
for (const sid of this.io.sockets.sockets.keys()) {
const s = this.io.sockets.sockets.get(sid);
if (s && s.userData && s.userData.userId === identifier && s.connected) return s;
}
return null;
}
handleDisconnect(socketId, identifier) {
const gameIdFromMap = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handleDisconnect] Socket: ${socketId}, User: ${identifier}, GameID from map: ${gameIdFromMap}`);
const game = gameIdFromMap ? this.games[gameIdFromMap] : null;
if (game) {
if (game.gameState?.isGameOver) {
console.log(`[GameManager.handleDisconnect] Game ${gameIdFromMap} for user ${identifier} (socket ${socketId}) ALREADY OVER.`);
return;
}
// Находим информацию об игроке в инстансе игры по identifier
const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier);
if (playerInfoInGame && playerInfoInGame.socket?.id === socketId && !playerInfoInGame.isTemporarilyDisconnected) {
console.log(`[GameManager.handleDisconnect] Disconnecting socket ${socketId} matches active player ${identifier} (Role: ${playerInfoInGame.id}) in game ${gameIdFromMap}. Notifying GameInstance.`);
// Передаем роль, идентификатор и ключ персонажа в GameInstance
if (typeof game.handlePlayerPotentiallyLeft === 'function') {
game.handlePlayerPotentiallyLeft(playerInfoInGame.id, identifier, playerInfoInGame.chosenCharacterKey);
} else {
console.error(`[GameManager.handleDisconnect] CRITICAL: GameInstance ${gameIdFromMap} missing handlePlayerPotentiallyLeft!`);
this._cleanupGame(gameIdFromMap, "missing_reconnect_logic_on_disconnect_gm");
}
} else if (playerInfoInGame && playerInfoInGame.socket?.id !== socketId) {
console.log(`[GameManager.handleDisconnect] Disconnected socket ${socketId} is STALE for user ${identifier}. Active socket in game: ${playerInfoInGame.socket?.id}.`);
} else if (playerInfoInGame && playerInfoInGame.isTemporarilyDisconnected) {
console.log(`[GameManager.handleDisconnect] User ${identifier} (socket ${socketId}) disconnected while already temp disconnected. Reconnect timer handles final cleanup.`);
} else if (!playerInfoInGame) {
console.warn(`[GameManager.handleDisconnect] User ${identifier} mapped to game ${gameIdFromMap}, but not found in game.players. Clearing map.`);
if (this.userIdentifierToGameId[identifier] === gameIdFromMap) delete this.userIdentifierToGameId[identifier];
}
} else {
if (this.userIdentifierToGameId[identifier]) {
delete this.userIdentifierToGameId[identifier];
}
}
}
_cleanupGame(gameId, reason = 'unknown') {
// ... (Код _cleanupGame почти без изменений, но использует game.playerCount и game.ownerIdentifier)
console.log(`[GameManager._cleanupGame] Attempting cleanup for GameID: ${gameId}, Reason: ${reason}`);
const game = this.games[gameId];
if (!game) {
const pendingIdx = this.pendingPvPGames.indexOf(gameId);
if (pendingIdx > -1) { this.pendingPvPGames.splice(pendingIdx, 1); this.broadcastAvailablePvPGames(); }
for (const idKey in this.userIdentifierToGameId) { if (this.userIdentifierToGameId[idKey] === gameId) delete this.userIdentifierToGameId[idKey]; }
return false;
}
console.log(`[GameManager._cleanupGame] Cleaning up game ${game.id}. Owner: ${game.ownerIdentifier}. Reason: ${reason}. Players in game: ${game.playerCount}`);
if (typeof game.turnTimer?.clear === 'function') game.turnTimer.clear();
if (typeof game.clearAllReconnectTimers === 'function') game.clearAllReconnectTimers(); // Вызываем у GameInstance
if (game.gameState && !game.gameState.isGameOver) {
game.gameState.isGameOver = true;
}
let playersCleanedFromMap = 0;
Object.values(game.players).forEach(pInfo => { // Игроки теперь в game.players
if (pInfo?.identifier && this.userIdentifierToGameId[pInfo.identifier] === gameId) {
delete this.userIdentifierToGameId[pInfo.identifier];
playersCleanedFromMap++;
}
});
if (game.ownerIdentifier && this.userIdentifierToGameId[game.ownerIdentifier] === gameId &&
!Object.values(game.players).some(p=>p.identifier === game.ownerIdentifier)) { // Проверка, если владелец не в списке игроков
delete this.userIdentifierToGameId[game.ownerIdentifier];
playersCleanedFromMap++;
}
const pendingIdx = this.pendingPvPGames.indexOf(gameId);
if (pendingIdx > -1) this.pendingPvPGames.splice(pendingIdx, 1);
delete this.games[gameId];
console.log(`[GameManager._cleanupGame] Game ${game.id} instance deleted. Games left: ${Object.keys(this.games).length}. Pending: ${this.pendingPvPGames.length}. User map size: ${Object.keys(this.userIdentifierToGameId).length}`);
this.broadcastAvailablePvPGames();
return true;
}
getAvailablePvPGamesListForClient() {
// ... (Код без изменений, использует game.playerCount и game.ownerIdentifier)
return this.pendingPvPGames
.map(gameId => {
const game = this.games[gameId];
if (game && game.mode === 'pvp' && game.playerCount === 1 && (!game.gameState || !game.gameState.isGameOver)) {
// Находим первого игрока (владельца) в инстансе игры
const p1Entry = Object.values(game.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected);
let p1Username = 'Игрок';
let p1CharName = 'Неизвестный';
const ownerId = game.ownerIdentifier;
if (p1Entry && p1Entry.socket?.userData) {
p1Username = p1Entry.socket.userData.username || `User#${String(p1Entry.identifier).substring(0,4)}`;
const charData = dataUtils.getCharacterBaseStats(p1Entry.chosenCharacterKey);
p1CharName = charData?.name || p1Entry.chosenCharacterKey || 'Не выбран';
} else if (ownerId){ // Фоллбэк на поиск по ownerId, если p1Entry не найден или без userData
const ownerSocket = this._findClientSocketByIdentifier(ownerId);
p1Username = ownerSocket?.userData?.username || `Owner#${String(ownerId).substring(0,4)}`;
const ownerCharKey = game.playerCharacterKey; // Ключ персонажа первого игрока из GameInstance
const charData = ownerCharKey ? dataUtils.getCharacterBaseStats(ownerCharKey) : null;
p1CharName = charData?.name || ownerCharKey || 'Не выбран';
}
return { id: gameId, status: `Ожидает (${p1Username} за ${p1CharName})`, ownerIdentifier: ownerId };
}
return null;
})
.filter(info => info !== null);
}
broadcastAvailablePvPGames() {
const list = this.getAvailablePvPGamesListForClient();
this.io.emit('availablePvPGamesList', list);
}
handleRequestGameState(socket, identifier) {
const gameIdFromMap = this.userIdentifierToGameId[identifier];
console.log(`[GameManager.handleRequestGameState] User: ${identifier} (Socket: ${socket.id}) requests state. GameID from map: ${gameIdFromMap}`);
const game = gameIdFromMap ? this.games[gameIdFromMap] : null;
if (game) {
const playerInfoInGame = Object.values(game.players).find(p => p.identifier === identifier); // Ищем по identifier
console.log(`[GameManager.handleRequestGameState] Game ${gameIdFromMap} found. PlayerInfo: ${playerInfoInGame ? `Role: ${playerInfoInGame.id}, TempDisco: ${playerInfoInGame.isTemporarilyDisconnected}` : 'Not found in game.players'}`);
if (playerInfoInGame) {
if (game.gameState?.isGameOver) {
socket.emit('gameNotFound', { message: 'Ваша предыдущая игра уже завершена.' });
if(this.userIdentifierToGameId[identifier] === gameIdFromMap) delete this.userIdentifierToGameId[identifier];
return;
}
// Передаем РОЛЬ и НОВЫЙ СОКЕТ в GameInstance для обработки реконнекта
if (typeof game.handlePlayerReconnected === 'function') {
const reconnected = game.handlePlayerReconnected(playerInfoInGame.id, socket);
// ... (обработка результата reconnected, если нужно)
} else {
console.error(`[GameManager.handleRequestGameState] CRITICAL: GameInstance ${game.id} missing handlePlayerReconnected!`);
this._handleGameRecoveryError(socket, game.id, identifier, 'gi_missing_reconnect_method_gm');
}
} else {
this._handleGameRecoveryError(socket, gameIdFromMap, identifier, 'player_not_in_gi_players_reconnect_gm');
}
} else {
socket.emit('gameNotFound', { message: 'Активная игровая сессия не найдена.' });
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
}
}
_handleGameRecoveryError(socket, gameId, identifier, reasonCode) {
// ... (код без изменений)
console.error(`[GameManager._handleGameRecoveryError] Error recovering game (ID: ${gameId || 'N/A'}) for user ${identifier}. Reason: ${reasonCode}.`);
socket.emit('gameError', { message: 'Ошибка сервера при восстановлении состояния игры.' });
if (gameId && this.games[gameId]) { // Проверяем, что игра еще существует перед очисткой
this._cleanupGame(gameId, `recovery_error_${reasonCode}_for_${identifier}`);
} else if (this.userIdentifierToGameId[identifier]) {
// Если игра уже удалена, но пользователь все еще к ней привязан
const problematicGameId = this.userIdentifierToGameId[identifier];
if (this.games[problematicGameId]) { // Если она все же есть
this._cleanupGame(problematicGameId, `recovery_error_stale_map_${identifier}_reason_${reasonCode}`);
} else { // Если ее нет, просто чистим карту
delete this.userIdentifierToGameId[identifier];
}
}
// Если после _cleanupGame пользователь все еще привязан (маловероятно, но для гарантии)
if (this.userIdentifierToGameId[identifier]) delete this.userIdentifierToGameId[identifier];
socket.emit('gameNotFound', { message: 'Ваша игровая сессия была завершена из-за ошибки.' });
}
}
module.exports = GameManager;