File

tbl/src/game-tbl/game-tbl.service.ts

Description

Game Service responsible for main logic of games running

Index

Properties
Methods

Constructor

constructor(redisService: RedisService, gameGateWay: GameGateway, jwtService: JwtService, crashReportsService: CrashReportsService)

constructor

Parameters :
Name Type Optional
redisService RedisService No
gameGateWay GameGateway No
jwtService JwtService No
crashReportsService CrashReportsService No

Methods

Async addNewQuestionsToRound
addNewQuestionsToRound(gameCode: string, questions: AbstractRoundQuestion[])

Adds more questions to currect round

@param gameCode {string} - game code @param questions {AbstractRoundQuestion[]} - questions to add

Parameters :
Name Type Optional Description
gameCode string No
  • game code
questions AbstractRoundQuestion[] No
  • questions to add
Returns : Promise<void>
Async checkAndUpdateEmptyPlayerRounds
checkAndUpdateEmptyPlayerRounds(gameCode: string, currentRound: number)

Check players stats if current round has empty rounds array and add zero stats to it

@param gameCode {string} redis game code @param currentRound {number} current round number

@return {void}

Parameters :
Name Type Optional Description
gameCode string No

redis game code

currentRound number No

current round number

Returns : Promise<void>
Async clientGame
clientGame(clientId: string)

Search client's game code by redis registrations

@param clientId {string} - client id

@return {string | null} - game code or null if not found

@todo should be deprecated from moment of gameCode exists in socket io connection jwt and accessible via client.data.user.gameCode

Parameters :
Name Type Optional Description
clientId string No
  • client id
Returns : Promise<string | null>
  • game code or null if not found
Async clientGameInfo
clientGameInfo(clientId: string, gameCode: string)

Get only game info (no players, no stats) where client joined (registered)

@param clientId {string} - client id

@return {RedisGame | null} - game data or null if not found

Parameters :
Name Type Optional Description
clientId string No
  • client id
gameCode string No
Returns : Promise<RedisGame>
  • game data or null if not found
Async clientIsReady
clientIsReady(gameCode: string, clientId: string)

Check user to be ready - game is unpacked and user data is unpacked

@param game {string} - game code @param clientId {string} - client id

@return {boolean} - should be true

Parameters :
Name Type Optional Description
gameCode string No
clientId string No
  • client id
Returns : Promise<boolean>
  • should be true
Async collectGameInfo
collectGameInfo(gameCode: string, onlineOnly)

Collects all game information including players and stats

@param gameCode {string} - game code

@return {RedisGame | null} - game data or null if not found

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code
onlineOnly No false
Returns : Promise<RedisGame>
  • game data or null if not found
Async findGame
findGame(gameCode: string)

Find game info in redis by gameCode Not contains stats and may contain players artifacts but don't use it here (to get game players - use collectGameInfo or getAllPlayers)

Parameters :
Name Type Optional
gameCode string No

game data

Async gameHostInfo
gameHostInfo(gameCode: string)

Get game host info

@param gameCode {string} - game code

@return {RedisHostInfo} - host info

Parameters :
Name Type Optional Description
gameCode string No
  • game code
  • host info
Async gameSavedToDB
gameSavedToDB(redisGameCode: string, mongoGameCode: string)

Called by subscribption to redis service onSaveGame Observable after game saved

@param redisGameCode {string} - redis game code @param gameCode {string} - game code (redis or mongo)

Parameters :
Name Type Optional Description
redisGameCode string No
  • redis game code
mongoGameCode string No
Returns : Promise<void>
Async getAllPlayers
getAllPlayers(gameCode: string)

Game players - list of all players - connected, disconnected and kicked

@param gameCode

@return {RedisPlayerInfo[]} - list of game players

Parameters :
Name Type Optional
gameCode string No
  • list of game players
Async getGameLeaderboard
getGameLeaderboard(gameCode: string, questions)

Gets game leaderboard search at redis only

@param gameCode {string} - game code (redis or mongo) @param questions {boolean = false} - flag to include game questions

@return {LooseObject[]} - leaderboard records array

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code (redis or mongo)
questions No false

false} - flag to include game questions

  • leaderboard records array
Async getGamePlayerInfo
getGamePlayerInfo(gameCode: string, clientId: string)

Player info at game

@param clientId {string} - client id to get info @param gameCode {string} - redis game code to get info

@return {RedisPlayerInfo} - player info data or null if game/info not found

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code to get info
clientId string No
  • client id to get info
  • player info data or null if game/info not found
Async getTotalScoreOfEachPlayer
getTotalScoreOfEachPlayer(gameCode: string)

Gets game total scores

@param gameCode {string} - game code (redis or mongo)

@return {number[]} - array of game players scores

Parameters :
Name Type Optional Description
gameCode string No
  • game code (redis or mongo)
Returns : Promise<number[]>
  • array of game players scores
Async hostLeft
hostLeft(gameCode: string)

Host left - conscious action, not just disconnect. Mark game as finished, so noone can join it again.

There is no need to transfer game to mongo here - if game was finished - transfering already should be running/finished and if game was not finished - no need to transfer this

There is no need to call any redis clearing - if game was finished TTL of redis data already was set to 120sec, and in any case when last game player will disconnect - it will run setting TTL for game data to EMPTY_GAME_TTL sec, so all data will be cleared automatically

@param gameCode {string} - redis game code host left

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code host left
Returns : Promise<void>
Async hostPrepareRound
hostPrepareRound(gameCode: string, questions: AbstractRoundQuestion[])

Creates and prepares new round for game

@param gameCode {string} - redis game code @param questions {AbstractRoundQuestion[]} - pregenerated round questions

@return {object} - prepared round data (created) or null if game stats not found or previous round not finished

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code
questions AbstractRoundQuestion[] No
  • pregenerated round questions
Returns : Promise<RedisGameRound>
  • prepared round data (created) or null if game stats not found or previous round not finished
Async hostStartRound
hostStartRound(gameCode: string)

Host start prepared round (last one, should be not started)

@param gameCode {string} - game code

@return {RedisGameRound} - started round info

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<RedisGameStats>
  • started round info
Async playerConnected
playerConnected(clientId: string, gameCode: string)

Websocket connected handler. Checks game to exist and that it's running. Increment game connections counter

@param clientId {string} - websocket clientId from JWT

@return {LooseObject | null} - object with game code and player info on success (game found, clientId found, game not finished) or null

Parameters :
Name Type Optional Description
clientId string No
  • websocket clientId from JWT
gameCode string No
  • object with game code and player info on success (game found, clientId found, game not finished) or null
Async playerDisconnected
playerDisconnected(clientId: string, gameCode: string)

Websocket disconnection handler. Decrement game connections counter and set redis game TTL to EMPTY_GAME_TTLsec if it was last connection

@param clientId {string} - websocket client id from JWT

@return {LooseObject | null} - object with redis game code and player info (for non teacher creator), just object with game code (for teacher creator) or null if something goes wrong (ex: gameInfo was not found because of reconnection attempt after game data was removed from redis)

Parameters :
Name Type Optional Description
clientId string No
  • websocket client id from JWT
gameCode string No
  • object with redis game code and player info (for non teacher creator), just object with game code (for teacher creator) or null if something goes wrong (ex: gameInfo was not found because of reconnection attempt after game data was removed from redis)
Async playerLeave
playerLeave(clientId: string, gameCode: string, isHost: boolean)

Player left - conscious action, not just disconnect. Full "unregistration" player from game.

@param clientId {string} - player client id from JWT

@return {{isHost: boolean, gameCode: string}} - object with game code and flag if disconnector is host if game was found in redis, object with gameCode: null otherways

Parameters :
Name Type Optional Description
clientId string No
  • player client id from JWT
gameCode string No
isHost boolean No
Returns : Promise<boolean>
  • object with game code and flag if disconnector is host if game was found in redis, object with gameCode: null otherways
Async playerSelectedAvatar
playerSelectedAvatar(gameCode: string, clientId: string, avatar: string)

Player select avatar

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code
clientId string No
  • player clientId to update
avatar string No
  • avatar
Returns : Promise<void>
Async playerSelectedBackground
playerSelectedBackground(gameCode: string, clientId: string, background: string)

Player select background

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code
clientId string No
  • player clientId to update
background string No
  • background
Returns : Promise<void>
Async playerSelectedCustomAvatar
playerSelectedCustomAvatar(gameCode: string, clientId: string, customAvatar: literal type)

Player select custom avatar

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code
clientId string No
  • player clientId to update
customAvatar literal type No
  • custom avatar
Returns : Promise<void>
Async playerSelectedFrame
playerSelectedFrame(gameCode: string, clientId: string, frame: string)

Player select frame

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code
clientId string No
  • player clientId to update
frame string No
  • frame
Returns : Promise<void>
Async playerStats
playerStats(clientId: string, gameCode: string)

Player stats at game

@param clientId {string} - client id to get stats @param gameCode {string} - redis game code to get stats

@return {RedisPlayerStats} - player stats data or null if game/stats not found

Parameters :
Name Type Optional Description
clientId string No
  • client id to get stats
gameCode string No
  • redis game code to get stats
  • player stats data or null if game/stats not found
Async removePlayer
removePlayer(clientId: string, kick)

Remove/kick player from game (kicked can not join again? right now - can)

@param clientId {string} - player client id to remove/kick @param kick {boolean = false} - flat to kick player (disallow rejoin to game) or just remove (allow rejoin)

@return {string | null} redis game code players was removed or null

Parameters :
Name Type Optional Default value Description
clientId string No
  • player client id to remove/kick
kick No false

false} - flat to kick player (disallow rejoin to game) or just remove (allow rejoin)

Returns : Promise<string | null>

redis game code players was removed or null

Async saveGameToDB
saveGameToDB(redisCode: string)

Save game to mongo

Parameters :
Name Type Optional Description
redisCode string No
  • game code in redis
Returns : Promise<string>
  • game permanent code in mongo or null on fail
Async savePlayerAnswer
savePlayerAnswer(gameCode: string, clientId: string, data: null)

Save player answer to redis

@param gameCode {string} - gameCode @param clientId {string} - clientId @param data {object} - answer data

@return {RedisPlayerStats} - updated player stats or null if game not found

Parameters :
Name Type Optional Default value Description
gameCode string No
  • gameCode
clientId string No
  • clientId
data null No null
  • answer data
  • updated player stats or null if game not found
Async setCurrentRoundFinished
setCurrentRoundFinished(gameCode: string)

Marks current round as finished

@param gameCode {string} redis game code

@return {boolean} game is finished (it was last round) or not

Parameters :
Name Type Optional Description
gameCode string No

redis game code

Returns : Promise<boolean>

game is finished (it was last round) or not

sortBoosterPointsDesc
sortBoosterPointsDesc(a: LooseObject, b: LooseObject)

Desc sorting helper

Parameters :
Name Type Optional
a LooseObject No
b LooseObject No
Returns : number
sortPointsDesc
sortPointsDesc(a: LooseObject, b: LooseObject)

Sorting helper

Parameters :
Name Type Optional
a LooseObject No
b LooseObject No
Returns : number
Async updateClassCodeForTheGame
updateClassCodeForTheGame(gameCode: string, newClassCode: string, requireLogin: boolean)

Updates class for game Available classes are prepared on came creation, so here is no mongo requests

@param gameCode {string} - game code in redis @param newClassCode {string} - new class code @param requireLogin {boolean} - require login flag for game

@return {boolean} - update success or not

Parameters :
Name Type Optional Description
gameCode string No
  • game code in redis
newClassCode string No
  • new class code
requireLogin boolean No
  • require login flag for game
Returns : Promise<boolean>
  • update success or not

Properties

Private Readonly logger
Default value : new Logger(GameService.name)

logger

import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import * as moment from 'moment';
import { getAnswerWeight } from '../../../shared/utils/questions';

import {
  RedisPlayerInfo,
  RedisGame,
  RedisHostInfo,
  RedisPlayerStats,
  RedisGameRound,
  RedisGameStats,
  AbstractRoundQuestion,
  AbstractLeaderboardRecord,
  TBLGameEndDTO,
} from '../../../shared/interfaces/game';

import { RedisService } from './redis.service';
import { GameGateway } from './game-tbl.gateway';
import { LooseObject } from '../../../shared/common/types';
import { JwtService } from '@nestjs/jwt';
import { CrashReportsService } from '../../../shared/common/crash-reports.service';
import {
  CatchAndLogError,
  ApplyDecoratorToAll,
} from '../../../shared/common/catch-and-log-error.decorator';

const EMPTY_GAME_TTL = 20 * 60; // 20min

/**
 *  Game Service responsible for main logic of games running
 */
@Injectable()
@ApplyDecoratorToAll(CatchAndLogError(RedisService.name), {
  syncFunc: ['sortPointsDesc', 'sortBoosterPointsDesc'],
})
export class GameService {
  /** logger */
  private readonly logger = new Logger(GameService.name);

  /** constructor */
  constructor(
    private readonly redisService: RedisService,
    @Inject(forwardRef(() => GameGateway))
    private readonly gameGateWay: GameGateway,
    private readonly jwtService: JwtService,
    private readonly crashReportsService: CrashReportsService,
  ) {}

  /**
   *  Check user to be ready - game is unpacked and user data is unpacked
   *
   *  @param game {string} - game code
   *  @param clientId {string} - client id
   *
   *  @return {boolean} - should be true
   *
   */
  async clientIsReady(gameCode: string, clientId: string): Promise<boolean> {
    const game = await this.redisService.unpackGame(gameCode);
    if (!game) {
      return false;
    }
    await this.redisService.unpackPlayer(game, clientId);
    return true;
  }

  /**
   *  Search client's game code by redis registrations
   *
   *  @param clientId {string} - client id
   *
   *  @return {string | null} - game code or null if not found
   *
   *  @todo should be deprecated from moment of gameCode exists in
   *        socket io connection jwt and accessible via client.data.user.gameCode
   */
  async clientGame(clientId: string): Promise<string | null> {
    const gameCode = await this.redisService.rget(`${clientId}-player`);
    if (!gameCode) return null;
    return `${gameCode}`;
  }

  /**
   *  Get only game info (no players, no stats) where client joined (registered)
   *
   *  @param clientId {string} - client id
   *
   *  @return {RedisGame | null} - game data or null if not found
   */
  async clientGameInfo(clientId: string, gameCode: string): Promise<RedisGame> {
    const info = await this.findGame(gameCode);
    if (!info) return null;
    const players = await this.redisService.gamePlayers(gameCode);
    if (players.some((p) => p.clientId === clientId && p.kicked)) return null;
    return info;
  }

  /**
   *  Collects all game information including players and stats
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisGame | null} - game data or null if not found
   */
  async collectGameInfo(
    gameCode: string,
    onlineOnly = false,
  ): Promise<RedisGame> {
    const info = await this.findGame(gameCode);
    const stats = await this.redisService.gameStats(gameCode);
    const players = await this.redisService.gamePlayers(gameCode, onlineOnly);
    return {
      ...info,
      gameRounds: stats ? stats.rounds : [],
      players,
    };
  }

  /**
   * Find game info in redis by gameCode
   * Not contains stats and may contain players artifacts but don't use it here
   * (to get game players - use `collectGameInfo` or `getAllPlayers`)
   *
   * @param   {string}  gameCode
   *
   * @return  {RedisGame | null}  game data
   */
  async findGame(gameCode: string): Promise<RedisGame | null> {
    // this.logger.log(`sarching game redis ${gameCode}`);
    return await this.redisService.gameInfo(gameCode);
  }

  /**
   *  Remove/kick player from game (kicked can not join again? right now - can)
   *
   *  @param clientId {string} - player client id to remove/kick
   *  @param kick {boolean = false} - flat to kick player (disallow rejoin to game) or just remove (allow rejoin)
   *
   *  @return {string | null} redis game code players was removed or null
   */
  async removePlayer(clientId: string, kick = false): Promise<string | null> {
    const gameCode = await this.clientGame(clientId);
    if (!gameCode) return null;
    this.logger.log(`${gameCode} removed a player ${clientId}`);
    const res = await this.redisService.removePlayer(gameCode, clientId, kick);
    return res ? gameCode : null;
  }

  /**
   *  Websocket connected handler.
   *  Checks game to exist and that it's running.
   *  Increment game connections counter
   *
   *  @param clientId {string} - websocket clientId from JWT
   *
   *  @return {LooseObject | null} - object with game code and player info on success (game found, clientId found, game not finished) or null
   */
  async playerConnected(
    clientId: string,
    gameCode: string,
  ): Promise<LooseObject | null> {
    const game = await this.redisService.unpackGame(gameCode);
    if (!game) {
      return null;
    }
    const playerInfo = await this.redisService.unpackPlayer(game, clientId);
    const gameConnections = await this.redisService.gameConnections(game.code);
    this.logger.log(`${gameCode} has ${gameConnections} connections`);
    if (!playerInfo) {
      const hostInfo = await this.redisService.gameHostInfo(gameCode);
      if (hostInfo && hostInfo.clientId === clientId) return { gameCode };
      return null;
    }
    game.players = game.players.map((p) =>
      p.clientId === clientId ? playerInfo : p,
    );
    await this.redisService.updateGamePlayerInfo(gameCode, playerInfo);
    await this.redisService.updateGameInfo(gameCode, game);
    return { gameCode, playerInfo };
  }

  /**
   *  Websocket disconnection handler.
   *  Decrement game connections counter and set redis game TTL to EMPTY_GAME_TTLsec if it was last connection
   *
   *  @param clientId {string} - websocket client id from JWT
   *
   *  @return {LooseObject | null} -
   *    object with redis game code and player info (for non teacher creator),
   *    just object with game code (for teacher creator) or null if something
   *    goes wrong (ex: gameInfo was not found because of reconnection
   *    attempt after game data was removed from redis)
   */
  async playerDisconnected(
    clientId: string,
    gameCode: string,
  ): Promise<LooseObject | null> {
    const gameInfo = await this.redisService.gameInfo(gameCode);
    if (!gameInfo) return null;
    const connections = await this.redisService.decrementGameConnections(
      gameCode,
    );
    // when player was kicked/removed - all required actions about managing
    // redis game/player data already done, and we need just check if anyone else
    // still connected to game. at such case player clientId should be found
    // as kicked. Same if player is marked as disconnected.
    const managed = gameInfo.players.find(
      (p) => p.clientId === clientId && (p.kicked || !p.isConnected),
    );
    if (!connections || connections < 0) {
      // No one connected to the game anymore - clear redis, but
      // keep data for 20 mins if users disconnection is a problem
      // and some users will reconnect back
      this.logger.log(
        `${gameCode} ttl ${EMPTY_GAME_TTL}. ${connections} connections`,
      );
      await this.redisService.updateGameTtl(gameCode, null, EMPTY_GAME_TTL);
    }
    if (managed) return { gameCode, playerInfo: { clientId } };

    const playerInfo = await this.redisService.gamePlayerInfo(
      gameCode,
      clientId,
    );
    await this.redisService.updateGameTtl(
      null,
      clientId,
      EMPTY_GAME_TTL,
      gameCode,
    );
    await this.redisService.updateGameInfo(gameInfo.code, gameInfo, true);
    if (!playerInfo) {
      // teacher
      const hostInfo = await this.redisService.gameHostInfo(gameInfo.code);
      if (hostInfo.clientId === clientId) {
        // yep, teacher
        hostInfo.isConnected = false;
        hostInfo.lastDisconnect = moment().format();
        this.logger.log(
          `teacher websocket disconnected ${clientId} ${gameCode}`,
        );
        await this.redisService.updateHostInfo(gameInfo.code, hostInfo);
        if (!connections || connections < 0) {
          // No one connected to the game anymore - clear redis, but
          // keep data for 20 mins if users disconnection is a problem
          // and some users will reconnect back
          this.logger.log(
            `${gameCode} ttl ${EMPTY_GAME_TTL}. ${connections} connections`,
          );
          await this.redisService.updateGameTtl(gameCode, null, EMPTY_GAME_TTL);
        }
        return { gameCode, isHost: true };
      } else {
        this.logger.error(
          // eslint-disable-next-line
          `${gameCode} ${clientId} unknown player disconnection (no info and not creator)`,
        );
        return null;
      }
    }
    playerInfo.isConnected = false;
    await this.redisService.updateGamePlayerInfo(gameCode, playerInfo, true);
    return { gameCode, playerInfo };
  }

  /**
   *  Get game host info
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisHostInfo} - host info
   */
  async gameHostInfo(gameCode: string): Promise<RedisHostInfo> {
    return this.redisService.gameHostInfo(gameCode);
  }

  /**
   *  Player left - conscious action, not just disconnect.
   *  Full "unregistration" player from game.
   *
   *  @param clientId {string} - player client id from JWT
   *
   *  @return {{isHost: boolean, gameCode: string}} - object with game code and flag if disconnector is host if game was found in redis, object with gameCode: null otherways
   */
  async playerLeave(
    clientId: string,
    gameCode: string,
    isHost: boolean,
  ): Promise<boolean> {
    const gameInfo = await this.findGame(gameCode);
    if (!gameInfo) return false;
    this.logger.log(`${gameCode} ${clientId} player leave - ${isHost}`);
    if (!isHost) {
      await this.redisService.removePlayer(gameCode, clientId);
    } else {
      await this.redisService.unregisterPlayer(gameCode, clientId);
    }
    return true;
  }

  /**
   *  Host left - conscious action, not just disconnect.
   *  Mark game as finished, so noone can join it again.
   *
   *  There is no need to transfer game to mongo here -
   *  if game was finished - transfering already should be running/finished
   *  and if game was not finished - no need to transfer this
   *
   *  There is no need to call any redis clearing - if game was finished
   *  TTL of redis data already was set to 120sec, and in any case when
   *  last game player will disconnect - it will run setting TTL for game data
   *  to EMPTY_GAME_TTL sec, so all data will be cleared automatically
   *
   *  @param gameCode {string} - redis game code host left
   */
  async hostLeft(gameCode: string): Promise<void> {
    const gameInfo = await this.redisService.gameInfo(gameCode);
    if (!gameInfo) return null;
    gameInfo.finished = moment().format();
    await this.redisService.updateGameInfo(gameCode, gameInfo);
  }

  /**
   *  Player stats at game
   *
   *  @param clientId {string} - client id to get stats
   *  @param gameCode {string} - redis game code to get stats
   *
   *  @return {RedisPlayerStats} - player stats data or null if game/stats not found
   */
  async playerStats(
    clientId: string,
    gameCode: string,
  ): Promise<RedisPlayerStats> {
    const ret = await this.redisService.gamePlayerStats(gameCode, clientId);
    return ret;
  }

  /**
   *  Game players - list of all players - connected, disconnected and kicked
   *
   *  @param gameCode
   *
   *  @return {RedisPlayerInfo[]} - list of game players
   */
  async getAllPlayers(gameCode: string): Promise<RedisPlayerInfo[]> {
    return await this.redisService.gamePlayers(gameCode);
  }

  /**
   *  Player info at game
   *
   *  @param clientId {string} - client id to get info
   *  @param gameCode {string} - redis game code to get info
   *
   *  @return {RedisPlayerInfo} - player info data or null if game/info not found
   */
  async getGamePlayerInfo(
    gameCode: string,
    clientId: string,
  ): Promise<RedisPlayerInfo> {
    return await this.redisService.gamePlayerInfo(gameCode, clientId);
  }

  /**
   *  Creates and prepares new round for game
   *
   *  @param gameCode {string} - redis game code
   *  @param questions {AbstractRoundQuestion[]} - pregenerated round questions
   *
   *  @return {object} - prepared round data (created) or null if
   *                     game stats not found or previous round not finished
   */
  async hostPrepareRound(
    gameCode: string,
    questions: AbstractRoundQuestion[],
  ): Promise<RedisGameRound> {
    const game = await this.redisService.gameInfo(gameCode);
    const stats = await this.redisService.gameStats(gameCode);
    if (!stats) return null;
    const round = stats.rounds.length + 1;

    if (round > 1) {
      const prevRound = stats.rounds[round - 2];
      // if there is not finished previous round - return it
      // round marks as finished only by host currentRoundFinished websocket event
      if (prevRound.started && !prevRound.hasEnded) {
        this.logger.error(
          `game ${gameCode} host prepare round ${round} with not finished prev`,
        );
        return null;
      }
    }

    const roundData = {
      started: null,
      endTime: null,
      round,
      hasEnded: false,
      rounds: game.round.rounds,
      questions,
    };

    stats.rounds.push(roundData);
    await this.redisService.updateGameStats(gameCode, stats);

    return roundData;
  }

  /**
   *  Host start prepared round (last one, should be not started)
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisGameRound} - started round info
   */
  async hostStartRound(gameCode: string): Promise<RedisGameStats> {
    const stats = await this.redisService.gameStats(gameCode);
    if (!stats) return null;
    const roundNum = stats.rounds.length - 1;
    if (roundNum < 0) {
      this.logger.error(`game ${gameCode} host start -1 round!`);
      return null;
    }
    if (stats.rounds[roundNum].started || stats.rounds[roundNum].endTime) {
      this.logger.error(
        `game ${gameCode} host start not finished round ${roundNum + 1}!`,
      );
      return null;
    }

    // starting round

    const game = await this.redisService.gameInfo(gameCode);
    const started = moment().format();
    // Duration + 3 second count down + 1 sec for buffer.
    const roundDurationPlusStartCountDown = game.round.roundDuration + 4;

    this.logger.log(`${gameCode} host start round. ${roundNum + 1}`);
    if (roundNum === 0) {
      // 1st round
      game.started = started;
      stats.started = started;
      await this.redisService.updateGameInfo(gameCode, game);
      void this.crashReportsService.increaseLiveGameDailyCounter(
        'startedGames',
      );
    }
    stats.rounds[roundNum].started = started;
    stats.rounds[roundNum].endTime = moment()
      .add(roundDurationPlusStartCountDown, 'seconds')
      .format();
    await this.redisService.updateGameStats(gameCode, stats);

    return stats;
  }

  /**
   *  Save player answer to redis
   *
   *  @param gameCode {string} - gameCode
   *  @param clientId {string} - clientId
   *  @param data {object} - answer data
   *
   *  @return {RedisPlayerStats} - updated player stats or null if game not found
   */
  async savePlayerAnswer(
    gameCode: string,
    clientId: string,
    data = null,
  ): Promise<RedisPlayerStats | null> {
    if (!gameCode || !data) return null;
    const gameInfo = await this.findGame(gameCode);
    if (gameInfo.finished) return null;
    const playerStats = await this.redisService.gamePlayerStats(
      gameCode,
      clientId,
    );
    // Without this the EMPTY_PLAYER_RESULT const
    // gets its rounds property updated as well.
    const rounds = [...playerStats.rounds];
    playerStats.rounds = [...rounds];

    const { correct } = data.questionBody;
    const { round } = data;
    const roundIndex = round - 1;

    if (playerStats.rounds.length < round) {
      for (let i = 0; i < round; i++) {
        if (!playerStats.rounds[i]) {
          playerStats.rounds[i] = {
            boosterScore: 0,
            score: 0,
            correctAnswers: 0,
            wrongAnswers: 0,
          };
        }
      }
    }
    if (correct) {
      playerStats.score += 1;
      playerStats.rounds[roundIndex].score += 1;
      playerStats.correctAnswers += 1;
      playerStats.rounds[roundIndex].correctAnswers += 1;
    } else {
      playerStats.score -= 1;
      playerStats.wrongAnswers += 1;
      playerStats.rounds[roundIndex].score -= 1;
      playerStats.rounds[roundIndex].wrongAnswers += 1;
      if (playerStats.score < 0) playerStats.score = 0;
      if (playerStats.rounds[roundIndex].score < 0)
        playerStats.rounds[roundIndex].score = 0;
    }
    const push = {
      ...data,
      answerCount: playerStats.wrongAnswers + playerStats.correctAnswers,
    };
    if (!playerStats.answers) playerStats.answers = [];
    if (!playerStats.answers[roundIndex]) playerStats.answers[roundIndex] = [];
    playerStats.answers[roundIndex].push(push);

    // update XP
    // getAnswerWeight method of xpService doesn't makes any db requests - only calculation by js hardcoded tables and question
    const questionWeight = getAnswerWeight(data.questionBody);
    if (!playerStats.xp) playerStats.xp = 0;
    playerStats.xp += questionWeight;

    await this.redisService.updatePlayerStats(gameCode, clientId, playerStats);
    return playerStats;
  }

  /**
   *  Check players stats if current round has empty rounds array and add zero
   *  stats to it
   *
   *  @param gameCode {string} redis game code
   *  @param currentRound {number} current round number
   *
   *  @return {void}
   */
  async checkAndUpdateEmptyPlayerRounds(
    gameCode: string,
    currentRound: number,
  ): Promise<void> {
    const playerStats = await this.redisService.gamePlayersStats(gameCode);

    await Promise.all(
      Object.entries(playerStats).map(([key, value]) => {
        if (value.rounds.length < currentRound) {
          value.rounds.push({
            boosterScore: 0,
            score: 0,
            correctAnswers: 0,
            wrongAnswers: 0,
          });
          return this.redisService.updatePlayerStats(gameCode, key, value);
        }
      }),
    );
  }

  /**
   *  Marks current round as finished
   *
   *  @param gameCode {string} redis game code
   *
   *  @return {boolean} game is finished (it was last round) or not
   */
  async setCurrentRoundFinished(gameCode: string): Promise<boolean> {
    const stats = await this.redisService.gameStats(gameCode); // 2nd arg default = 'stats'
    const roundIndex = stats.rounds.length - 1;
    if (roundIndex < 0) {
      this.logger.error('finishing round index error!', roundIndex);
      return;
    }
    const currentRound = stats.rounds[roundIndex].round;
    stats.rounds[roundIndex].hasEnded = new Date();
    const ended = currentRound === stats.rounds[roundIndex].rounds;
    if (ended) {
      stats.finished = moment().format();
      const gameInfo = await this.redisService.gameInfo(gameCode);
      if (gameInfo) {
        gameInfo.finished = moment().format();
        await this.redisService.updateGameInfo(gameCode, gameInfo);
      }
    }

    this.logger.log(`${gameCode} round end: ${currentRound} game: ${ended}`);
    await this.redisService.updateGameStats(gameCode, stats);
    await this.checkAndUpdateEmptyPlayerRounds(gameCode, currentRound);
    return ended;
  }

  /**
   *  Gets game leaderboard
   *  search at redis only
   *
   *  @param gameCode {string} - game code (redis or mongo)
   *  @param questions {boolean = false} - flag to include game questions
   *
   *  @return {LooseObject[]} - leaderboard records array
   */
  async getGameLeaderboard(
    gameCode: string,
    questions = false,
  ): Promise<AbstractLeaderboardRecord[]> {
    const gameInfo = await this.redisService.gameInfo(gameCode);
    if (gameInfo) {
      const playersRaw = await this.redisService.rhgetall(
        `${gameCode}-players`,
        true,
      );
      const players = Object.values(playersRaw || {});
      const stats = await this.redisService.gamePlayersStats(gameCode);
      const board = players
        .filter((player) => {
          // filter kicked
          if (player.kicked) return false;
          // filter disconnected without any round playing
          if (!player.isConnected && !stats[player.clientId].rounds.length)
            return false;
          return true;
        })
        .map((player) => {
          const stat = stats[player.clientId];
          const playerObj = {
            name: player.name,
            clientId: player.clientId,
            avatar: player.avatar,
            customAvatar: player?.customAvatar,
            background: player.background,
            frame: player.frame,
            score: stat.score,
            correctAnswers: stat.correctAnswers,
            wrongAnswers: stat.wrongAnswers,
            rounds: stat.rounds.map((round) => ({
              score: round.score,
              boosterScore: round.boosterScore,
              correctAnswers: round.correctAnswers,
              wrongAnswers: round.wrongAnswers,
            })),
            questions: [],
            highscore: playersRaw[player.clientId].highScores || {},
          };
          if (questions) {
            const allAnswers = [];
            (stat.answers || []).forEach((list) => {
              // may happen if user skip round cuz of disconnection
              if (!Array.isArray(list)) return;
              list.forEach((el) => {
                // remove null fields
                el.questionBody = Object.fromEntries(
                  Object.entries(el.questionBody).filter(
                    ([, val]) => val !== null,
                  ),
                );
                allAnswers.push(el);
              });
            });
            playerObj.questions = allAnswers.sort((a, b) => {
              const ca = a.answerCount as number;
              const cb = b.answerCount as number;
              return ca < cb ? -1 : 1;
            });
          }
          return playerObj;
        })
        .sort(this.sortPointsDesc);
      return board;
    }
    return [];
  }

  /**
   *  Called by subscribption to redis service onSaveGame Observable after game saved
   *
   *  @param redisGameCode {string} - redis game code
   *  @param gameCode {string} - game code (redis or mongo)
   */
  async gameSavedToDB(
    redisGameCode: string,
    mongoGameCode: string,
  ): Promise<void> {
    this.logger.log(`${redisGameCode} saved to mongo - ${mongoGameCode}`);
    const gameInfo = await this.redisService.gameInfo(redisGameCode);
    await this.redisService.updateGameInfo(redisGameCode, {
      ...gameInfo,
      mongoCode: mongoGameCode,
      saved: true,
    });
    await this.redisService.updateGameTtl(redisGameCode, null, 120);
    this.gameGateWay.gameSavedToDB(redisGameCode, mongoGameCode);
  }

  /**
   *  Gets game total scores
   *
   *  @param gameCode {string} - game code (redis or mongo)
   *
   *  @return {number[]} - array of game players scores
   */
  async getTotalScoreOfEachPlayer(gameCode: string): Promise<number[]> {
    const leaderboard = await this.getGameLeaderboard(gameCode);
    return (leaderboard && leaderboard.map((plr) => plr.score)) || [];
  }

  /**
   *  Sorting helper
   */
  sortPointsDesc(a: LooseObject, b: LooseObject): number {
    if (a.score > b.score) {
      return -1;
    }
    if (a.score < b.score) {
      return 1;
    }
    return 0;
  }

  /**
   *  Desc sorting helper
   */
  sortBoosterPointsDesc(a: LooseObject, b: LooseObject): number {
    if (a.boosterScore > b.boosterScore) {
      return -1;
    }
    if (a.boosterScore < b.boosterScore) {
      return 1;
    }
    return 0;
  }

  /**
   *  Updates class for game
   *  Available classes are prepared on came creation, so here is no mongo requests
   *
   *  @param gameCode {string} - game code in redis
   *  @param newClassCode {string} - new class code
   *  @param requireLogin {boolean} - require login flag for game
   *
   *  @return {boolean} - update success or not
   */
  async updateClassCodeForTheGame(
    gameCode: string,
    newClassCode: string,
    requireLogin: boolean,
  ): Promise<boolean> {
    const gameInfo = await this.redisService.gameInfo(gameCode);
    if (
      !gameInfo ||
      !gameInfo.teacherData ||
      !gameInfo.teacherData.classes.includes(`${newClassCode}`)
    ) {
      return false;
    }
    gameInfo.classCode = newClassCode;
    gameInfo.requireLogin = requireLogin;
    const updRes = await this.redisService.updateGameInfo(gameCode, gameInfo);
    if (!updRes) {
      this.logger.error(
        `updating game ${gameCode} class ${newClassCode} in redis failed`,
      );
    }
    return true;
  }

  /**
   * Player select avatar
   *
   * @todo refactor to redis and clientId
   *
   * @param gameCode {string} - redis game code
   * @param clientId {string} - player clientId to update
   * @param avatar {string} - avatar
   */
  async playerSelectedAvatar(
    gameCode: string,
    clientId: string,
    avatar: string,
  ): Promise<void> {
    const playerInfo = await this.redisService.gamePlayerInfo(
      gameCode,
      clientId,
    );
    if (!playerInfo) return;
    playerInfo.avatar = avatar;
    await this.redisService.updateGamePlayerInfo(gameCode, playerInfo);
  }

  /**
   * Player select custom avatar
   *
   * @todo refactor to redis and clientId
   *
   * @param gameCode {string} - redis game code
   * @param clientId {string} - player clientId to update
   * @param customAvatar {object} - custom avatar
   */
  async playerSelectedCustomAvatar(
    gameCode: string,
    clientId: string,
    customAvatar: { [key: string]: string },
  ): Promise<void> {
    const playerInfo = await this.redisService.gamePlayerInfo(
      gameCode,
      clientId,
    );
    if (!playerInfo) return;
    playerInfo.customAvatar = customAvatar;
    await this.redisService.updateGamePlayerInfo(gameCode, playerInfo);
  }

  /**
   * Save game to mongo
   *
   * @param redisCode {string} - game code in redis
   * @param attempt {number} - save attempt for recursive call
   *
   * @return {string | null} - game permanent code in mongo or null on fail
   */
  async saveGameToDB(redisCode: string): Promise<string> {
    this.logger.log(`${redisCode} checking saveGameToDB`);
    const redisGame = await this.findGame(redisCode);
    if (!redisGame) {
      this.logger.error(`Saving game ${redisCode} failed - no redis data!`);
      return null;
    }
    const existingGamePack = await this.redisService.rget(
      `${redisCode}-endpack`,
    );
    // if ((redisGame.saved && !attempt) || redisGame.mongoCode) {
    if (redisGame.mongoCode || existingGamePack) {
      this.logger.error(`Game ${redisCode} already saving/saved!`);
      return redisGame.mongoCode || null;
    }
    const gameStats = await this.redisService.rhgetall(
      `${redisCode}-game`,
      true,
    );
    const gamePlayers = await this.redisService.gamePlayers(redisCode);
    const pack: TBLGameEndDTO = {
      info: gameStats.info,
      stats: gameStats.stats,
      host: gameStats.host,
      pstats: Object.keys(gameStats)
        .filter((k) => !['info', 'stats', 'host'].includes(k))
        .reduce((acc, pid) => {
          acc[pid] = gameStats[pid];
          return acc;
        }, {}),
      players: gamePlayers,
    };
    const packed = await this.redisService.rset(
      `${redisCode}-endpack`,
      JSON.stringify(pack),
    );
    if (packed) {
      this.logger.log(`${redisCode} packed to save`);
      // PUBLISH saveGame event to core via redis pub/sub
      this.redisService.publishGameSave(redisCode);
    } else {
      this.logger.error(`${redisCode} not packed to save`);
    }

    return null;
  }

  /**
   *  Adds more questions to currect round
   *
   *  @param gameCode {string} - game code
   *  @param questions {AbstractRoundQuestion[]} - questions to add
   */
  async addNewQuestionsToRound(
    gameCode: string,
    questions: AbstractRoundQuestion[],
  ): Promise<void> {
    const gameStats = await this.redisService.gameStats(gameCode);
    const round = gameStats.rounds.length - 1;
    if (round < 0) return;
    gameStats.rounds[round].questions =
      gameStats.rounds[round].questions.concat(questions);
    await this.redisService.updateGameStats(gameCode, gameStats);
  }

  /**
   * Player select background
   *
   * @todo refactor to redis and clientId
   *
   * @param gameCode {string} - redis game code
   * @param clientId {string} - player clientId to update
   * @param background {string} - background
   */
  async playerSelectedBackground(
    gameCode: string,
    clientId: string,
    background: string,
  ): Promise<void> {
    const playerInfo = await this.redisService.gamePlayerInfo(
      gameCode,
      clientId,
    );
    if (!playerInfo) return;
    playerInfo.background = background;
    await this.redisService.updateGamePlayerInfo(gameCode, playerInfo);
  }

  /**
   * Player select frame
   *
   * @todo refactor to redis and clientId
   *
   * @param gameCode {string} - redis game code
   * @param clientId {string} - player clientId to update
   * @param frame {string} - frame
   */
  async playerSelectedFrame(
    gameCode: string,
    clientId: string,
    frame: string,
  ): Promise<void> {
    const playerInfo = await this.redisService.gamePlayerInfo(
      gameCode,
      clientId,
    );
    if (!playerInfo) return;
    playerInfo.frame = frame;
    await this.redisService.updateGamePlayerInfo(gameCode, playerInfo);
  }
}

results matching ""

    No results matching ""