'use strict';
const
generateId = reqfromroot('generateId'),
Log = reqfromroot('logger');
class Game{
/**
* Базовый класс игры.
* Предоставляет игровой цикл.
* Предоставляет методы для ожидания и получения действий от игроков.
* Создает игровые компоненты и управляет ими.
* @param {Queue} queue очередь, к которой принадлежит игра
* @param {Player[]} players массив игроков.
* @param {object<class>} Classes классы, из которых создаются игровые компоненты
* @param {object} config настройки игры
* @param {object} rules правила игры
*/
constructor(queue, players, Classes, config, rules){
/**
* Игровые настройки.
* @type {Object}
*/
this.config = config;
/**
* Правила игры.
* @type {Object}
*/
this.rules = rules;
// Генерируем айди игры
let id = generateId();
/**
* id игры
* @type {String}
*/
this.id = 'game_' + id;
/**
* Логгер игры.
* @type {winston.Logger}
*/
this.log = Log(module, id, config.debug);
/**
* Очередь, создавшая игру.
* @type {Queue}
*/
this.queue = queue;
/**
* Класс ботов для добавления, если не хватает игроков,
* и для замены вышедших игроков.
* @type {Player}
*/
this.BotClass = Classes.bot;
// Добавляем бота, если игрок один
while(players.length < config.minPlayers){
players.push(this.queue.createBot(['Mistake'], true));
this.log.warn('Only %s players at the start of the game, adding a bot', players.length);
}
/**
* Игровые состояния.
* @type {GameStates}
*/
this.states = new Classes.states(this);
/**
* Стадии ходов игры.
* @type {GameTurnStages}
*/
this.turnStages = new Classes.turnStages(this);
/**
* Обработчик ответа от игроков.
* @type {GameActions}
*/
this.actions = new Classes.actions(this, players);
/**
* Методы, выполняемые в ответ на действия от игроков.
* @type {GameReactions}
*/
this.reactions = new Classes.reactions();
/**
* Методы, позволяющие игрокам выполнять действия.
* @type {GameDirectives}
*/
this.directives = new Classes.directives();
/**
* Менеджер игроков и ботов, учавствующих в игре.
* @type {GamePlayers}
*/
this.players = new Classes.players(this, players.slice());
/**
* Менеджер игровых карт.
* @type {GameCards}
*/
this.cards = new Classes.cards(this);
// Добавляем указатели на поля карт
/**
* Колода карт из {@link Game#cards}.
* @type {array}
*/
this.deck = this.cards.deck;
/**
* Стопка сброса из {@link Game#cards}.
* @type {array}
*/
this.discardPile = this.cards.discardPile;
/**
* Стол из {@link Game#cards}.
* @type {array}
*/
this.table = this.cards.table;
/**
* Руки игроков из {@link Game#cards}.
* @type {array}
*/
this.hands = this.cards.hands;
/**
* Индекс игры.
* @type {Number}
*/
this.index = -1;
/**
* Индекс хода.
* @type {Number}
*/
this.turnIndex = 0;
/**
* Таймер ожидания ответа от игроков.
* @type {Timer}
*/
this.timer = null;
/**
* Время начала текущего хода.
* @type {number}
*/
this.turnStartTime = null;
/**
* Результаты игры.
* @type {object}
*/
this.result = null;
/**
* Находится ли игра в ускоренном режиме.
* @type {Boolean}
*/
this.simulating = false;
/**
* Время ответа ботов.
* Уменьшается, если стоит флаг `simulating`.
* @type {number}
*/
this.fakeDecisionTimer = 200;
this.defaultFakeDecisionTimer = this.fakeDecisionTimer;
/**
* Является ли игра тестом.
* @type {Boolean}
*/
this.isTest = config.test;
/**
* Активна ли игра.
* Неактивные игры удаляются из менеджера игры, но ссылки на них могут оставаться у ботов.
* @type {Boolean}
*/
this.active = false;
/**
* Время после которого игрок будет отключен от игры.
* @type {number}
*/
this.disconnectTimeout = 30*1000;
}
/**
* Запущена ли игра.
* Игра не запущена, когда идет голосование о рестарте.
* Это не тоже самое, что game.states.current == 'STARTED'
* @return {Boolean}
*/
get isRunning(){
return this.states.current != 'NOT_STARTED';
}
// Методы игры
/** Инициализация и запуск первой игры. */
init(){
this.log.info(this.rules);
this.active = true;
this.reset();
this.start();
}
/** Ресет игры */
reset(){
// Свойства игры
this.index++;
this.states.current = 'NOT_STARTED';
this.result = this.getDefaultResults();
this.resetSimulating();
this.players.reset();
this.players.resetGame();
this.cards.reset();
this.actions.reset();
// Свойства хода
this.turnIndex = 1;
this.turnStages.next = 'DEFAULT';
}
/** Подготовка и начало игры */
start(){
this.log.notice('Game started', this.index);
this.states.current = 'SHOULD_START';
// Перемешиваем игроков
this.players.shuffle();
// Создаем карты, поля и колоду
this.cards.make();
let note = {
type: 'GAME_STARTED',
index: this.index
};
this.players.notify(note);
// Начинаем игру
// jshint curly:false
while(this.continue());
}
/** Заканчивает игру, оповещает игроков и позволяет им голосовать за рематч */
end(){
this.log.info('Game ended', this.id, '\n\n');
this.players.gameStateNotify(this.players, {cards: true}, true, 'REVEAL', true);
let action = this.getResults();
this.reset();
this.actions.setValid(action.actions);
this.waitForResponse(this.actions.timeouts.gameEnd, this.players);
this.players.notify(action);
}
/**
* Преждевременное завершение игры.
* Не производит правильное отключение игроков, используется только, если все игроки боты.
*/
shutdown(){
if(this.players.humans.length){
this.log.error(new Error(`Can't shutdown game with human players in it`));
return;
}
this.active = false;
this.log.notice('Shutting down');
clearTimeout(this.timer);
this.players.reset(true);
}
/**
* Перезапускает игру. Оповещает игроков о результатах голосования.
* @param {object} voteResults результаты голосования за рематч.
*/
rematch(voteResults){
this.log.info('Rematch');
// Оповещаем игроков о результате голосования
this.players.notify(voteResults);
this.actions.reset();
this.start();
}
/**
* Возвращает игру в лобби. Оповещает игроков о результатах голосования.
* @param {object} voteResults результаты голосования за рематч.
*/
backToQueue(voteResults){
this.active = false;
// Оповещаем игроков о результате голосования
if(voteResults){
this.players.notify(voteResults);
}
this.players.reset(true);
this.log.info('No rematch');
this.queue.endGame(voteResults ? voteResults.results : null);
}
// РЕЗУЛЬТАТЫ
/**
* Возвращает результаты игры для передачи игрокам.
* @return {object}
*/
getResults(){
let results = {};
for(let key in this.result){
if(!this.result.hasOwnProperty(key)){
continue;
}
let val = this.result[key];
if(val && val.slice){
results[key] = this.result[key].slice();
}
else if(typeof val == 'object'){
results[key] = Object.assign({}, val);
}
else{
results[key] = this.result[key];
}
}
let action = {
type: 'GAME_ENDED',
scores: this.players.scores,
results: results,
actions: [{type: 'ACCEPT'}, {type: 'DECLINE'}]
};
return action;
}
/**
* Возвращает объект, в который будут записываться результаты игры.
* @return {objec}
*/
getDefaultResults(){
return {
winners: null,
loser: null
};
}
// СИМУЛЯЦИЯ (когда в игре остались только боты)
/** Если остались только боты, убираем игроков из списка ожидания ответа, чтобы ускорить игру. */
trySimulating(){
let humanActivePlayers = this.players.getWithOwn('type', 'player', this.players.active);
if(!humanActivePlayers.length){
this.log.notice('Simulating');
this.players.notify({type: 'SIMULATING'}, this.players.humans);
this.simulating = true;
this.fakeDecisionTimer = 0;
}
}
/** Убирает статус симуляции, оповещает игроков. */
resetSimulating(){
if(this.simulating && !this.isTest){
this.players.notify({type: 'STOP_SIMULATING'}, this.players.humans);
}
this.simulating = this.isTest;
if(this.simulating){
this.fakeDecisionTimer = 0;
}
else{
this.fakeDecisionTimer = this.defaultFakeDecisionTimer;
}
}
// Методы хода
/** Сбрасываем счетчики и стадию игры */
resetTurn(){
this.log.info('Turn Ended', (Date.now() - this.turnStartTime)/1000);
this.table.usedFields = 0;
this.turnStages.reset();
this.actions.reset();
this.players.resetTurn();
this.players.notify({type: 'TURN_ENDED'});
this.players.concedeDisconnected();
}
/** Начинает ход */
startTurn(){
this.players.logTurnStart();
this.turnStartTime = Date.now();
// Увеличиваем счетчик ходов, меняем стадию игры на первую атаку и продолжаем ход
this.turnIndex++;
this.players.notify({
type: 'TURN_STARTED',
index: this.turnIndex
});
this.turnStages.setNext('INITIAL_ATTACK');
}
// Методы выбора статуса игры и стадии хода
/**
* Выполняет следующую стадию игры.
* @return {boolean} Возвращает нужно ли продолжать игру, или ждать игроков.
*/
continue(){
/*this.players.notify({
states.current: this.states.current,
turnStages.next: this.turnStages.next
})*/
if(!this.active){
this.log.error(new Error('Game inactive'));
return;
}
let state = this.states[this.states.current];
if(!state){
this.log.error(new Error(`invalid game state ${this.states.current}`));
return false;
}
return state.call(this.states);
}
/**
* Выполняет следующую стадию хода
* @return {boolean} Возвращает нужно ли продолжать игру, или ждать игроков.
*/
doTurn(){
let turnStage = this.turnStages[this.turnStages.next];
if(!turnStage){
this.log.error(new Error(`Invalid turn stage ${this.turnStages.next}`));
return false;
}
return turnStage.call(this.turnStages);
}
/**
* Позволяет игроку выполнить действие
* @param {string} dirName название выполняемого действия
* @param {...Player} players игроки, которым разрешено действовать
*
* @return {boolean} Возвращает нужно ли продолжать игру, или ждать игроков.
*/
let(dirName, ...players){
let directive = this.directives[dirName];
if(!directive){
this.log.error(new Error(`Invalid directive ${dirName}`));
return false;
}
return directive.call(this, ...players);
}
// Методы установки таймера действий
/**
* Ждет ответа от игроков.
* @param {number} time время ожидания в секундах
* @param {Player[]} players игроки, которых нужно ждать
*/
waitForResponse(time, players){
if(this.timer){
clearTimeout(this.timer);
this.timer = null;
}
this.actions.completeNotify();
if(!this.simulating){
this.trySimulating();
}
if(this.simulating){
players = this.players.getWithOwn('type', 'bot', players);
}
this.players.working = players;
if(players.length){
let duration = time * 1000;
// Если игрок afk, время действия уменьшается
if(this.players.allAfk(players)){
duration = this.actions.timeouts.afk * 1000;
}
this.actions.deadline = Date.now() + duration;
this.timer = setTimeout(() => this.timeOut(), duration);
return this.actions.deadline;
}
else{
this.log.error(new Error('Set to wait for response but nobody to wait for'));
return 0;
}
}
/**
* Выполняется по окончании таймера ответа игроков
* Выполняет случайное действие или продолжает игру
*/
timeOut(){
this.timer = null;
this.players.logTimeout();
// Если есть действия, выполняем первое попавшееся действие
if(this.actions.hasValid() && this.states.current == 'STARTED'){
this.actions.executeFirst();
}
// Иначе, обнуляем действующих игроков, возможные действия и продолжаем ход
else{
this.players.working = [];
this.actions.clearValid();
// jshint curly:false
while(this.continue());
}
}
// Методы обработки действий
/**
* Получает ответ от игрока асинхронно.
* @param {Player} player
* @param {object} action выполненное игроком действие
*/
recieveResponse(player, action){
setTimeout(() => {
this.recieveResponseSync.call(this, player, action);
}, 0);
}
/**
* Получает ответ от игрока синхронно.
* Используется для тестов, асинхронность должна быть добавлена при вызове функции для корректной работы.
* @param {Player} player
* @param {object} action выполненное игроком действие
*/
recieveResponseSync(player, action){
if(!this.active){
this.log.warn('Game inactive, can\'t recieve response', player.id, player.type, action);
return;
}
this.actions.recieve(player, action);
}
// Игрок проносит курсор над картой
/**
* Сообщает игрокам, что игрок держит курсор над определенной картой.
* @param {Player} player
* @param {string} cid id карты
*/
hoverOverCard(player, cid){
if(this.isRunning && this.players.includes(player) && player.statuses.working && this.actions.valid[player.id].length && this.cards.byId[cid].field == player.id){
if(player.statuses.hover){
this.hoverOutCard(player, cid);
}
player.statuses.hover = cid;
let players = [];
this.players.forEach((p) => {
if(p != player){
players.push(p);
}
});
this.players.notify({type: 'HOVER_OVER_CARD', cid: cid, noResponse: true, channel: 'extra'}, players);
this.log.silly('Hovering over', cid, player.id);
}
}
/**
* Сообщает игрокам, что игрок убрал курсор с карты.
* @param {Player} player
* @param {string} [cid] id карты
*/
hoverOutCard(player, cid){
if(this.isRunning && this.players.includes(player) && player.statuses.hover){
let players = [];
this.players.forEach((p) => {
if(p != player){
players.push(p);
}
});
this.players.notify({type: 'HOVER_OUT_CARD', cid: player.statuses.hover, noResponse: true, channel: 'extra'}, players);
player.statuses.hover = null;
this.log.silly('Hovering out');
}
}
}
/**
* Максимальное кол-во игроков в игре.
* @type {Number}
*/
Game.maxPlayers = 6;
/**
* Минимальное кол-во игроков в игре.
* @type {Number}
*/
Game.minPlayers = 2;
/**
* Название режима игры.
* @type {string}
* @default 'game'
*/
Game.modeName = 'game';
/**
* {@link Game}
* @module
*/
module.exports = Game;