File

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

Description

Application redis service. Used by tbl game service and pub/sub channel connection with core app

Index

Properties
Methods

Constructor

constructor(gameService: GameService, configService: ConfigService)

constructor

Parameters :
Name Type Optional
gameService GameService No
configService ConfigService No

Methods

Async addPlayer
addPlayer(gameCode: string, playerInfo: RedisPlayerInfo)

Add player to game. Register player and sets player info at hset ${gameCode} -> `${clientId} -> RedisPlayerInfo

@param gameCode {string} - game code @param playerInfo {RedisPlayerInfo} - player info

@return {boolean} - success or fail

Parameters :
Name Type Optional Description
gameCode string No
  • game code
playerInfo RedisPlayerInfo No
  • player info
Returns : Promise<boolean>
  • success or fail
Async clearGame
clearGame(gameCode: string)

Immediately removes game data from redis including players and creator links to game ${clientId}-player -> ${gameCode}

@param gameCode {string} - game code

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<void>
Async createGame
createGame(game: RedisGame, hostId: string)

Creates new game

@param game {RedisGame} - game info

Parameters :
Name Type Optional Description
game RedisGame No
  • game info
hostId string No
Returns : Promise<RedisGame>
Async decrementGameConnections
decrementGameConnections(gameCode: string)

Decrement number of connections to game

@param gameCode {string} - game code

@return {number} - updated number of connections

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<number>
  • updated number of connections
Async gameConnections
gameConnections(gameCode: string)

Number of connections to game

@param gameCode {string} - game code

@return {number} - number of connections

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<number>
  • number of connections
Async gameHostInfo
gameHostInfo(gameCode: string)

Host info

@param gameCode {string} - game code

@return {RedisHostInfo} - game host info

Parameters :
Name Type Optional Description
gameCode string No
  • game code
  • game host info
Async gameInfo
gameInfo(gameCode: string)

Basic game info. Used if information stats/players is not required, because in hset ${gameCode}-game -> 'info' players data is not stored (or stored without guaranty to be updated) while game is running. Final full game info is merged from all game-related redis keys/fields on game saving to mongo.

@param gameCode {string} - game code

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

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<RedisGame>
  • game info or null if game not found
Async gamePlayerInfo
gamePlayerInfo(gameCode: string, clientId: string)

Game player info

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

@return {RedisPlayerInfo} - player info or null

Parameters :
Name Type Optional Description
gameCode string No
  • game code
clientId string No
  • client id
  • player info or null
Async gamePlayers
gamePlayers(gameCode: string, onlineOnly)

All game players including disconnected and kicked

@param gameCode {string} - game code

@return {RedisPlayerInfo[]} - array of game players or empty array if game/players not found

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code
onlineOnly No false
  • array of game players or empty array if game/players not found
Async gamePlayersStats
gamePlayersStats(gameCode: string)

Game players stats (all players)

@param gameCode {string} - game code

@return {{[clientId:string]: RedisPlayerStats}} - object with {clientId} -> RedisPlayerStats data or null if game not found

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<literal type>
  • object with {clientId} -> RedisPlayerStats data or null if game not found
Async gamePlayerStats
gamePlayerStats(gameCode: string, clientId: string)

Game player stats

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

@return {RedisPlayerStats} - player stats in game or null

Parameters :
Name Type Optional Description
gameCode string No
  • game code
clientId string No
  • client id
  • player stats in game or null
Async gameStats
gameStats(gameCode: string)

Game stats

@param gameCode {string} - game code

@return {RedisGameStats} - game stats

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<RedisGameStats>
  • game stats
Async incrementGameConnections
incrementGameConnections(gameCode: string)

Increment number of connections to game

@param gameCode {string} - game code

@return {number} - updated number of connections

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<number>
  • updated number of connections
Async playerDisconnected
playerDisconnected(clientId: string)

Player disconnection handler Removes player from game if player's game exists

@param clientId {string} - client id

Parameters :
Name Type Optional Description
clientId string No
  • client id
Returns : Promise<void>
publishGameSave
publishGameSave(gameCode: string)

Method to publish for core event to save finished game

@param gameCode {string} - redis game code

Parameters :
Name Type Optional Description
gameCode string No
  • redis game code
Returns : void
Async registerPlayer
registerPlayer(gameCode: string, clientId: string)

Register player (ex: teacher or student) at game. Set ${clientId}-player -> gameCode relation

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

@return {boolean} - success or fail

Parameters :
Name Type Optional Description
gameCode string No
  • game code
clientId string No
  • client id
Returns : Promise<boolean>
  • success or fail
Async removePlayer
removePlayer(gameCode: string, clientId: string, kick, clear)

Removes/kick player from game

@param gameCode {string} - game code @param clientId {string} - client id @param kick {boolean = false} - flag to kick (block client reconnections to this game) @param clear {boolean = true} - flag to clear player data from redis @return {boolean} - success/fail result

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code
clientId string No
  • client id
kick No false

false} - flag to kick (block client reconnections to this game)

clear No true

true} - flag to clear player data from redis

Returns : Promise<boolean>
  • success/fail result
rget
rget(key: string, unjson)

Redis GET async helper with parsing json option

@param key {string} - redis key to read as string @param unjson {boolean = false} - flag to parse resut as json and return parsed

@return {string|null|any} - null on error/not existing key, string or object of key value

Parameters :
Name Type Optional Default value Description
key string No
  • redis key to read as string
unjson No false

false} - flag to parse resut as json and return parsed

  • null on error/not existing key, string or object of key value
rhget
rhget(key: string, field: string, unjson)

Redis HGET async helper with parsing json option

@param key {string} - redis key to read @param field {string} - redis field to read as string @param unjson {boolean = false} - flag to parse resut as json and return parsed

@return {string|null|any} - null on error/not existing key, string or object of key value

Parameters :
Name Type Optional Default value Description
key string No
  • redis key to read
field string No
  • redis field to read as string
unjson No false

false} - flag to parse resut as json and return parsed

  • null on error/not existing key, string or object of key value
rhgetall
rhgetall(key: string, unjson)

Redis HGETALL async helper with parsing json support

@param key {string} - redis key to read @param field {string} - redis field to read as string @param unjson {boolean = false} - flag to parse resut as json and return parsed

@return {object|null} - null on error/not existing key, object of key value with string or json parsed fields

Parameters :
Name Type Optional Default value Description
key string No
  • redis key to read
unjson No false

false} - flag to parse resut as json and return parsed

  • null on error/not existing key, object of key value with string or json parsed fields
rset
rset(key: string, value: string)

Redis SET async helper with converting to json option

@param key {string} - redis key to set as string @param value {any} - redis value to set (finally - as string) @param json {boolean = false} - flag to convert value as json

@return {Promise} - success/failed

Parameters :
Name Type Optional Description
key string No
  • redis key to set as string
value string No
  • redis value to set (finally - as string)
Returns : Promise<boolean>
  • success/failed
Async unpackGame
unpackGame(gameCode: string)

Unpack (create) game from core

@param gameCode {string} - game code

@return {boolean} - success on player unpacked or exists

Parameters :
Name Type Optional Description
gameCode string No
  • game code
Returns : Promise<RedisGame>
  • success on player unpacked or exists
Async unpackPlayer
unpackPlayer(game: RedisGame, clientId: string)

Unpack player on connection to websocket

@param game {RedisGame} - game info @param clientId {string} - connected player/host clientId

@return {boolean} - success on player unpacked or exists

Parameters :
Name Type Optional Description
game RedisGame No
  • game info
clientId string No
  • connected player/host clientId
  • success on player unpacked or exists
Async unregisterPlayer
unregisterPlayer(gameCode: string, clientId: string, ttl: number)

Unregister player (student/teacher/...) from game Set ${clientId}-player ttl to {ttl} sec. if player was unregistered by some lag - they have 30sec to reconnect back

@param gameCode {string} - game code @param playerInfo {RedisPlayerInfo} - player info

@return {boolean} - success or fail

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code
clientId string No
ttl number No 30
Returns : Promise<boolean>
  • success or fail
Async updateGameInfo
updateGameInfo(gameCode: string, info: RedisGame, noTtl)

Update game info

@param gameCode {string} - game code @param info {RedisGame} - basic game info @param noTtl {boolean = false} - flag to not update game data ttl in redis

@return {boolean} - success/fail result

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code
info RedisGame No
  • basic game info
noTtl No false

false} - flag to not update game data ttl in redis

Returns : Promise<boolean>
  • success/fail result
Async updateGamePlayerInfo
updateGamePlayerInfo(gameCode: string, playerInfo: RedisPlayerInfo, noTtl)

Update game player info

@param gameCode {string} - game code @param playerInfo {RedisPlayerInfo} - client info

@return {boolean} - success/fail result

Parameters :
Name Type Optional Default value Description
gameCode string No
  • game code
playerInfo RedisPlayerInfo No
  • client info
noTtl No false
Returns : Promise<void>
  • success/fail result
Async updateGameStats
updateGameStats(gameCode: string, stats: RedisGameStats)

Update game stats

@param gameCode {string} - game code @param stats {RedisGameStats} - client id

@return {boolean} - success/fail result

Parameters :
Name Type Optional Description
gameCode string No
  • game code
stats RedisGameStats No
  • client id
Returns : Promise<boolean>
  • success/fail result
Async updateGameTtl
updateGameTtl(gameCode?: string, clientId?: string, ttl?: number, clientGameCode?: string)

Updates redis game data ttl for game or user or both

@param gameCode {string?} - game code to update @param clientId {string?} - client id to upda @param ttl {number?} - custom ttl (default is taken from env.CACHE_TTL_GAME)

Parameters :
Name Type Optional Description
gameCode string Yes
  • game code to update
clientId string Yes
  • client id to upda
ttl number Yes
  • custom ttl (default is taken from env.CACHE_TTL_GAME)
clientGameCode string Yes
Returns : Promise<void>
Async updateHostInfo
updateHostInfo(gameCode: string, info: RedisHostInfo)

Update game host info

@param gameCode {string} - game code @param info {RedisHostInfo} - host info

@return {boolean} - success/fail result

Parameters :
Name Type Optional Description
gameCode string No
  • game code
info RedisHostInfo No
  • host info
Returns : Promise<boolean>
  • success/fail result
Async updatePlayerStats
updatePlayerStats(gameCode: string, clientId: string, stats: RedisPlayerStats)

Updates player stats

@param gameCode {string} - game code @param clientId {string} - client id @param stats {RedisPlayerStats} - player stats

Parameters :
Name Type Optional Description
gameCode string No
  • game code
clientId string No
  • client id
stats RedisPlayerStats No
  • player stats
Returns : Promise<void>

Properties

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

logger

Private Readonly pubClient

core channel pub client

Private Readonly redisClient

main redis reader/writer

Private Readonly subClient

core channel sub client

Private Readonly ttl
Default value : process.env.CACHE_TTL_TBL_GAME || -1

time to live for redis records

import { Inject, Injectable, forwardRef, Logger } from '@nestjs/common';
import { LooseObject } from '../../../shared/common/types';
import * as moment from 'moment';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';

import {
  RedisGame,
  RedisGameStats,
  RedisHostInfo,
  RedisPlayerInfo,
  RedisPlayerStats,
  TBLGameCreateDTO,
} from '../../../shared/interfaces/game';

import { GameService } from './game-tbl.service';
import {
  CatchAndLogError,
  ApplyDecoratorToAll,
} from '../../../shared/common/catch-and-log-error.decorator';

/*

  Redis storage doc v2:

    clientId = user.userId || user.guestId

    get:    `${gameCode}-pack`          -> game creation dto
    get:    `${gameCode}-endpack`       -> game end dto
    get:    `${clientId}-player`        -> `${gameCode}`
    get:    `${gameCode}-${clientId}`   -> player connection info
    get:    `${gameCode}-connections`   -> number of connections. use incr/decr for atomic changes
    hget:   `${gameCode}-game`
                'info'                  -> RedisGame
                'stats'                 -> RedisGameStats
                'host'                  -> RedisHostInfo
                `${clientId}`           -> RedisPlayerStats
    hget:   `${gameCode}-players`
                `${clientId}`           -> RedisPlayerInfo

*/

/**
 *  Application redis service. Used by tbl game service and pub/sub channel connection with core app
 */
@Injectable()
@ApplyDecoratorToAll(CatchAndLogError(RedisService.name), {
  syncFunc: ['publishGameSave'],
})
export class RedisService {
  /** main redis reader/writer */
  private readonly redisClient;
  /** logger */
  private readonly logger = new Logger(RedisService.name);
  /** time to live for redis records */
  private readonly ttl = process.env.CACHE_TTL_TBL_GAME || -1;

  /** core channel pub client */
  private readonly pubClient;
  /** core channel sub client */
  private readonly subClient;

  /** constructor */
  constructor(
    @Inject(forwardRef(() => GameService))
    private readonly gameService: GameService,
    private readonly configService: ConfigService,
  ) {
    this.redisClient = new Redis({
      host: configService.get('TBL_REDIS_HOST'),
      port: parseInt(configService.get('TBL_REDIS_PORT'), 10),
      db: parseInt(configService.get('TBL_REDIS_DB'), 10) || 0,
    });

    this.redisClient.on('error', (err) => {
      this.logger.error('redis error', err);
    });
    this.redisClient.on('connect', () => {
      this.logger.log('redis connected');
    });
    this.redisClient.on('reconnecting', () => {
      this.logger.log('redis reconnecting');
    });
    this.redisClient.on('ready', () => {
      this.logger.log('redis ready');
    });
    this.redisClient.on('end', () => {
      this.logger.log('redis end');
    });

    this.pubClient = this.redisClient.duplicate();
    this.subClient = this.pubClient.duplicate();

    this.pubClient.on('error', (err) => {
      /* Simply catch the error, it's logged in GameModule. */
      this.logger.error('pub error', err);
    });
    this.subClient.on('error', (err) => {
      /* Simply catch the error, it's logged in GameModule. */
      this.logger.error('sub error', err);
    });

    this.subClient.subscribe('gameSaved');
    this.subClient.on('message', async (...data) => {
      let info = null;
      try {
        info = JSON.parse(data[1]);
      } catch (err) {
        this.logger.error(`json reading gameSaved message error ${err}`);
        info = null;
      }
      if (!info || !info.redisGameCode || !info.mongoGameCode) return;
      await this.gameService.gameSavedToDB(
        info.redisGameCode,
        info.mongoGameCode,
      );
    });
  }

  /**
   *  Method to publish for core event to save finished game
   *
   *  @param gameCode {string} - redis game code
   */
  publishGameSave(gameCode: string): void {
    this.pubClient.publish('saveGame', JSON.stringify({ gameCode }));
  }

  /**
   *  Redis SET async helper with converting to json option
   *
   *  @param key {string} - redis key to set as string
   *  @param value {any} - redis value to set (finally - as string)
   *  @param json {boolean = false} - flag to convert value as json
   *
   *  @return {Promise<boolean>} - success/failed
   */
  rset(
    key: string,
    // eslint-disable-next-line
    value: string,
  ): Promise<boolean> {
    return new Promise((resolve) => {
      this.redisClient.set(key, value, (err, val) => {
        if (err) return resolve(false);
        resolve(val === 'OK');
      });
    });
  }

  /**
   *  Redis GET async helper with parsing json option
   *
   *  @param key {string} - redis key to read as string
   *  @param unjson {boolean = false} - flag to parse resut as json and return parsed
   *
   *  @return {string|null|any} - null on error/not existing key, string or object of key value
   */
  rget(key: string, unjson = false): Promise<string | null | LooseObject> {
    return new Promise((resolve) => {
      this.redisClient.get(key, (err, val) => {
        if (err) return resolve(null);
        resolve(unjson ? JSON.parse(val) : val);
      });
    });
  }

  /**
   *  Redis HGET async helper with parsing json option
   *
   *  @param key {string} - redis key to read
   *  @param field {string} - redis field to read as string
   *  @param unjson {boolean = false} - flag to parse resut as json and return parsed
   *
   *  @return {string|null|any} - null on error/not existing key, string or object of key value
   */
  rhget(
    key: string,
    field: string,
    unjson = false,
  ): Promise<string | null | LooseObject> {
    return new Promise((resolve) => {
      this.redisClient.hget(key, field, (err, val) => {
        if (err) return resolve(null);
        resolve(unjson ? JSON.parse(val) : val);
      });
    });
  }

  /**
   *  Redis HGETALL async helper with parsing json support
   *
   *  @param key {string} - redis key to read
   *  @param field {string} - redis field to read as string
   *  @param unjson {boolean = false} - flag to parse resut as json and return parsed
   *
   *  @return {object|null} - null on error/not existing key, object of key value with string or json parsed fields
   */
  rhgetall(key: string, unjson = false): Promise<LooseObject | null> {
    return new Promise((resolve) => {
      this.redisClient.hgetall(key, (err, val) => {
        if (err || !val) return resolve(null);
        if (!unjson) return resolve(val);
        try {
          Object.keys(val || {}).forEach((ke) => {
            val[ke] = JSON.parse(val[ke]);
          });
        } catch (er) {
          this.logger.error('rhgetall unjson error', er);
          val = null;
        }
        resolve(val);
      });
    });
  }
  // End redis helpers

  /**
   *  Unpack (create) game from core
   *
   *  @param gameCode {string} - game code
   *
   *  @return {boolean} - success on player unpacked or exists
   */
  async unpackGame(gameCode: string): Promise<RedisGame> {
    let game = await this.gameInfo(gameCode);
    if (game) return game;
    this.logger.log(`${gameCode} unpack game`);
    const pack: TBLGameCreateDTO = (await this.rget(
      `${gameCode}-pack`,
      true,
    )) as TBLGameCreateDTO;
    if (!pack) return null;
    const hostId: string =
      typeof pack.host === 'string' ? pack.host : pack.host.clientId;
    try {
      this.logger.log(`${gameCode} CREATE`);
      game = await this.createGame(pack.game, hostId);
      if (typeof pack.host === 'string') {
        this.logger.log(`${gameCode} CREATE REG`);
        await this.registerPlayer(`${pack.game.code}`, pack.host);
        await this.updateGameTtl(gameCode, pack.host);
      } else {
        this.logger.log(`${gameCode} CREATE ADD`);
        await this.addPlayer(`${pack.game.code}`, pack.host);
      }
    } catch (err) {
      this.logger.error(`${pack.game.code} unpacking game error ${err}`);
      game = null;
    }
    return game;
  }

  /**
   *  Unpack player on connection to websocket
   *
   *  @param game {RedisGame} - game info
   *  @param clientId {string} - connected player/host clientId
   *
   *  @return {boolean} - success on player unpacked or exists
   */
  async unpackPlayer(
    game: RedisGame,
    clientId: string,
  ): Promise<RedisPlayerInfo> {
    const exists = await this.gamePlayerInfo(game.code, clientId);
    // console.log('HIex', exists);
    if (exists) {
      if (!exists.isConnected) {
        exists.isConnected = true;
        game.players = game.players.map((p) =>
          p.clientId === clientId ? exists : p,
        );
        await this.updateGamePlayerInfo(game.code, exists);
        await this.updateGameInfo(game.code, game);
        await this.incrementGameConnections(game.code);
      }
      return exists;
    }
    const hostInfo = await this.gameHostInfo(game.code);
    // console.log('HI', hostInfo);
    if (hostInfo.clientId === clientId) {
      await this.redisClient.set(`${clientId}-player`, game.code);
      await this.redisClient.expire(`${clientId}-player`, this.ttl);
      if (!hostInfo.isConnected) {
        hostInfo.isConnected = true;
        await this.updateHostInfo(game.code, hostInfo);
        await this.incrementGameConnections(game.code);
      }
      return null;
    }
    this.logger.log(`${game.code} unpack player ${clientId}`);
    const player = (await this.rget(
      `${game.code}-${clientId}`,
      true,
    )) as RedisPlayerInfo;
    if (!player) return null;
    let playerI = -1;
    game.players.forEach((p, pi) => {
      if (p.clientId === clientId) {
        // player = p;
        playerI = pi;
      }
    });
    let incConnections = false;
    player.isConnected = true;
    if (playerI === -1) {
      game.players.push(player);
      incConnections = true;
      // await this.incrementGameConnections(game.code);
    } else {
      this.logger.error(
        'connected player unpacked but exists in game.players - replacing',
      );
      if (!game.players[playerI].isConnected) {
        incConnections = true;
      }
      game.players[playerI] = player;
    }
    // if (!player || playerI === -1) return false;
    // if (player.isConnected) return true;
    // game.players[playerI].isConnected = true;
    await this.updateGameInfo(game.code, game);
    await this.redisClient.set(`${clientId}-player`, game.code);
    await this.redisClient.expire(`${clientId}-player`, this.ttl);
    await this.redisClient.hset(
      `${game.code}-players`,
      clientId,
      JSON.stringify(player),
    );
    await this.redisClient.hset(
      `${game.code}-game`,
      clientId,
      JSON.stringify({
        score: 0,
        boosterScore: 0,
        correctAnswers: 0,
        wrongAnswers: 0,
        rounds: [],
        xp: 0,
      }),
    );
    if (incConnections) await this.incrementGameConnections(game.code);
    await this.updateGameTtl(game.code, clientId);
    return player;
  }

  /**
   *  Creates new game
   *
   *  @param game {RedisGame} - game info
   */
  async createGame(game: RedisGame, hostId: string): Promise<RedisGame> {
    game.code = `${game.code}`;
    await this.redisClient.hset(
      `${game.code}-game`,
      'info',
      JSON.stringify(game),
    );
    await this.redisClient.hset(
      `${game.code}-game`,
      'stats',
      JSON.stringify({
        started: null,
        finished: null,
        rounds: [],
      }),
    );
    await this.redisClient.hset(
      `${game.code}-game`,
      'host',
      JSON.stringify({
        clientId: hostId,
        isConnected: false,
        lastDisconnect: null,
      }),
    );
    await this.redisClient.set(`${game.code}-connections`, 0, 'EX', this.ttl);
    await this.redisClient.expire(`${game.code}-game`, this.ttl);
    return game;
  }

  /**
   *  Number of connections to game
   *
   *  @param gameCode {string} - game code
   *
   *  @return {number} - number of connections
   */
  async gameConnections(gameCode: string): Promise<number> {
    const num = (await this.rget(`${gameCode}-connections`)) as string;
    // this.logger.log(`${gameCode} has ${num} connections`);
    return parseInt(num, 10) || 0;
  }

  /**
   *  Register player (ex: teacher or student) at game.
   *  Set `${clientId}-player` -> gameCode relation
   *
   *  @param gameCode {string} - game code
   *  @param clientId {string} - client id
   *
   *  @return {boolean} - success or fail
   */
  async registerPlayer(gameCode: string, clientId: string): Promise<boolean> {
    if (!gameCode || !clientId) return false;
    this.logger.log(`${gameCode} ${clientId} register player`);
    await this.redisClient.set(`${clientId}-player`, gameCode);
    await this.redisClient.expire(`${clientId}-player`, this.ttl);
    await this.updateGameTtl(gameCode, clientId);
    return true;
  }

  /**
   *  Add player to game. Register player and sets player info
   *  at hset `${gameCode}` -> `${clientId} -> RedisPlayerInfo
   *
   *  @param gameCode {string} - game code
   *  @param playerInfo {RedisPlayerInfo} - player info
   *
   *  @return {boolean} - success or fail
   */
  async addPlayer(
    gameCode: string,
    playerInfo: RedisPlayerInfo,
  ): Promise<boolean> {
    const register = await this.registerPlayer(gameCode, playerInfo.clientId);
    if (!register) {
      return false;
    }
    const players = await this.gamePlayers(gameCode);
    if (
      players.some(
        (p) =>
          p.name.toLowerCase() === playerInfo.name.toLowerCase() &&
          p.clientId !== playerInfo.clientId &&
          !p.kicked,
      )
    ) {
      await this.unregisterPlayer(gameCode, playerInfo.clientId);
      return false;
    }
    playerInfo.isConnected = false;
    const jinfo = JSON.stringify(playerInfo);
    await this.redisClient.hset(
      `${gameCode}-players`,
      playerInfo.clientId,
      jinfo,
    );
    await this.redisClient.hset(
      `${gameCode}-game`,
      playerInfo.clientId,
      JSON.stringify({
        score: 0,
        boosterScore: 0,
        correctAnswers: 0,
        wrongAnswers: 0,
        rounds: [],
        xp: 0,
      }),
    );
    await this.updateGameTtl(gameCode, playerInfo.clientId);
    return true;
  }

  /**
   *  Unregister player (student/teacher/...) from game
   *  Set `${clientId}-player` ttl to {ttl} sec.
   *  if player was unregistered by some lag - they have 30sec to reconnect back
   *
   *  @param gameCode {string} - game code
   *  @param playerInfo {RedisPlayerInfo} - player info
   *
   *  @return {boolean} - success or fail
   */
  async unregisterPlayer(
    gameCode: string,
    clientId: string,
    ttl = 30,
  ): Promise<boolean> {
    if (!clientId) return false;
    this.logger.log(`${gameCode} unregister player ${clientId}`);
    await this.redisClient.expire(`${clientId}-player`, ttl);
    await this.redisClient.expire(`${gameCode}-${clientId}`, ttl);
    return true;
  }

  /**
   *  Increment number of connections to game
   *
   *  @param gameCode {string} - game code
   *
   *  @return {number} - updated number of connections
   */
  async incrementGameConnections(gameCode: string): Promise<number> {
    await this.redisClient.incr(`${gameCode}-connections`);
    const ret = await this.gameConnections(gameCode);
    return ret;
  }

  /**
   *  Decrement number of connections to game
   *
   *  @param gameCode {string} - game code
   *
   *  @return {number} - updated number of connections
   */
  async decrementGameConnections(gameCode: string): Promise<number> {
    await this.redisClient.decr(`${gameCode}-connections`);
    const ret = await this.gameConnections(gameCode);
    return ret;
  }

  /**
   *  Removes/kick player from game
   *
   *  @param gameCode {string} - game code
   *  @param clientId {string} - client id
   *  @param kick {boolean = false} - flag to kick (block client reconnections to this game)
   *  @param clear {boolean = true} - flag to clear player data from redis
   *  @return {boolean} - success/fail result
   */
  async removePlayer(
    gameCode: string,
    clientId: string,
    kick = false,
    clear = true,
  ): Promise<boolean> {
    try {
      // don't clear player stats - player may reconnect
      this.logger.log(
        `${gameCode} ${kick ? 'kick' : 'remove'} ${clientId} clear ${clear}`,
      );
      const gameInfo = await this.gameInfo(gameCode);
      if (kick) {
        gameInfo.players = gameInfo.players.map((p) => {
          if (p.clientId !== clientId || p.kicked) return p;
          return {
            ...p,
            isConnected: false,
            kicked: moment().format(),
          };
        });
      } else {
        gameInfo.players = clear
          ? gameInfo.players.filter((p) => p.clientId !== clientId || p.kicked)
          : gameInfo.players.map((p) => {
              if (p.clientId !== clientId || p.kicked) return p;
              return {
                ...p,
                isConnected: false,
              };
            });
      }
      // clear player info
      if (clear) {
        await Promise.all([
          this.redisClient.hdel(`${gameCode}-game`, clientId),
          this.redisClient.hdel(`${gameCode}-players`, clientId),
          this.redisClient.del(`${clientId}-player`),
        ]);
      }
      await this.updateGameInfo(gameCode, gameInfo);
    } catch (err) {
      const errStr =
        err && typeof err.toString === 'function'
          ? err.toString()
          : JSON.stringify(err);
      this.logger.error(`removing player error ${errStr}`);
      return false;
    }
    return true;
  }

  /**
   *  Player disconnection handler
   *  Removes player from game if player's game exists
   *
   *  @param clientId {string} - client id
   */
  async playerDisconnected(clientId: string): Promise<void> {
    const gameCode = (await this.rget(`${clientId}-player`)) as string;
    if (!gameCode) return;
    await this.removePlayer(gameCode, clientId, false, false);
  }

  /**
   *  Basic game info. Used if information stats/players is not required,
   *  because in hset `${gameCode}-game` -> 'info' players data is not stored (or stored without guaranty to be updated) while game is running. Final full game info is merged from all game-related redis keys/fields on game saving to mongo.
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisGame} - game info or null if game not found
   */
  async gameInfo(gameCode: string): Promise<RedisGame> {
    const ret = (await this.rhget(
      `${gameCode}-game`,
      'info',
      true,
    )) as RedisGame;
    if (!ret) return null;
    ret.code = `${ret.code}`;
    const stats = await this.gameStats(gameCode);
    return { ...ret, gameRounds: [...stats.rounds] };
  }

  /**
   *  Update game info
   *
   *  @param gameCode {string} - game code
   *  @param info {RedisGame} - basic game info
   *  @param noTtl {boolean = false} - flag to not update game data ttl in redis
   *
   *  @return {boolean} - success/fail result
   */
  async updateGameInfo(
    gameCode: string,
    info: RedisGame,
    noTtl = false,
  ): Promise<boolean> {
    try {
      await this.redisClient.hset(
        `${info.code}-game`,
        'info',
        JSON.stringify(info),
      );
      if (!noTtl && !info.finished) {
        await this.updateGameTtl(gameCode);
      }
      return true;
    } catch (err) {
      const str = err
        ? typeof err.toString === 'function'
          ? err.toString()
          : JSON.stringify(err)
        : 'unknown';
      this.logger.error(`update game info error ${gameCode} ${str}`);
      return false;
    }
  }

  /**
   *  Game stats
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisGameStats} - game stats
   */
  async gameStats(gameCode: string): Promise<RedisGameStats> {
    const info = await this.rhget(`${gameCode}-game`, 'stats', true);
    return info as RedisGameStats;
  }

  /**
   *  Update game stats
   *
   *  @param gameCode {string} - game code
   *  @param stats {RedisGameStats} - client id
   *
   *  @return {boolean} - success/fail result
   */
  async updateGameStats(
    gameCode: string,
    stats: RedisGameStats,
  ): Promise<boolean> {
    if (!stats) return false;
    await this.redisClient.hset(
      `${gameCode}-game`,
      'stats',
      JSON.stringify(stats),
    );
    await this.updateGameTtl(gameCode);
    return true;
  }

  /**
   *  Host info
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisHostInfo} - game host info
   */
  async gameHostInfo(gameCode: string): Promise<RedisHostInfo> {
    const info = await this.rhget(`${gameCode}-game`, 'host', true);
    return info as RedisHostInfo;
  }

  /**
   *  Update game host info
   *
   *  @param gameCode {string} - game code
   *  @param info {RedisHostInfo} - host info
   *
   *  @return {boolean} - success/fail result
   */
  async updateHostInfo(
    gameCode: string,
    info: RedisHostInfo,
  ): Promise<boolean> {
    if (!info) return false;
    await this.redisClient.hset(
      `${gameCode}-game`,
      'host',
      JSON.stringify(info),
    );
    await this.updateGameTtl(gameCode);
    return true;
  }

  /**
   *  Game player info
   *
   *  @param gameCode {string} - game code
   *  @param clientId {string} - client id
   *
   *  @return {RedisPlayerInfo} - player info or null
   */
  async gamePlayerInfo(
    gameCode: string,
    clientId: string,
  ): Promise<RedisPlayerInfo | null> {
    const info = await this.rhget(`${gameCode}-players`, clientId);
    if (typeof info === 'string') return JSON.parse(info);
    return null;
  }

  /**
   *  Update game player info
   *
   *  @param gameCode {string} - game code
   *  @param playerInfo {RedisPlayerInfo} - client info
   *
   *  @return {boolean} - success/fail result
   */
  async updateGamePlayerInfo(
    gameCode: string,
    playerInfo: RedisPlayerInfo,
    noTtl = false,
  ): Promise<void> {
    const jinfo = JSON.stringify(playerInfo);
    await this.redisClient.hset(
      `${gameCode}-players`,
      playerInfo.clientId,
      jinfo,
    );
    if (!noTtl) await this.updateGameTtl(gameCode, playerInfo.clientId);
  }

  /**
   *  Game player stats
   *
   *  @param gameCode {string} - game code
   *  @param clientId {string} - client id
   *
   *  @return {RedisPlayerStats} - player stats in game or null
   */
  async gamePlayerStats(
    gameCode: string,
    clientId: string,
  ): Promise<RedisPlayerStats> {
    const info = await this.rhget(`${gameCode}-game`, clientId);
    if (typeof info === 'string') return JSON.parse(info);
    return null;
  }

  /**
   *  Updates player stats
   *
   *  @param gameCode {string} - game code
   *  @param clientId {string} - client id
   *  @param stats {RedisPlayerStats} - player stats
   */
  async updatePlayerStats(
    gameCode: string,
    clientId: string,
    stats: RedisPlayerStats,
  ): Promise<void> {
    const jstats = JSON.stringify(stats);
    await this.redisClient.hset(`${gameCode}-game`, clientId, jstats);
    await this.updateGameTtl(gameCode, clientId);
  }

  /**
   *  Game players stats (all players)
   *
   *  @param gameCode {string} - game code
   *
   *  @return {{[clientId:string]: RedisPlayerStats}} - object with {clientId} -> RedisPlayerStats data or null if game not found
   */
  async gamePlayersStats(
    gameCode: string,
  ): Promise<{ [cid: string]: RedisPlayerStats }> {
    const allStats = await this.rhgetall(`${gameCode}-game`, true);
    if (!allStats) return null;
    const players = await this.gamePlayers(gameCode);
    // filter from game fields, disconnected and kicked players
    return Object.keys(allStats).reduce((acc, key) => {
      if (key === 'info' || key === 'stats' || key === 'host') {
        return acc;
      }
      if (players[key] && players[key].kicked) return acc;
      return { ...acc, [key]: allStats[key] };
    }, {});
  }

  /**
   *  Updates redis game data ttl for game or user or both
   *
   *  @param gameCode {string?} - game code to update
   *  @param clientId {string?} - client id to upda
   *  @param ttl {number?} - custom ttl (default is taken from env.CACHE_TTL_GAME)
   */
  async updateGameTtl(
    gameCode?: string,
    clientId?: string,
    ttl?: number,
    clientGameCode?: string,
  ): Promise<void> {
    const nextTtl = typeof ttl === 'number' ? ttl : this.ttl;
    // this.logger.error(`updateGameTtl <${gameCode}> ${nextTtl}`);
    if (gameCode) {
      await this.redisClient.expire(`${gameCode}-pack`, nextTtl);
      await this.redisClient.expire(`${gameCode}-endpack`, nextTtl);
      await this.redisClient.expire(`${gameCode}-game`, nextTtl);
      await this.redisClient.expire(`${gameCode}-players`, nextTtl);
      await this.redisClient.expire(`${gameCode}-connections`, nextTtl);
    }
    if (clientId) {
      await this.redisClient.expire(`${clientId}-player`, nextTtl);
      const code = gameCode || clientGameCode;
      await this.redisClient.expire(`${code}-${clientId}`, nextTtl);
    }
  }

  /**
   *  All game players including disconnected and kicked
   *
   *  @param gameCode {string} - game code
   *
   *  @return {RedisPlayerInfo[]} - array of game players or empty array if game/players not found
   */
  async gamePlayers(
    gameCode: string,
    onlineOnly = false,
  ): Promise<RedisPlayerInfo[]> {
    const players = await this.rhgetall(`${gameCode}-players`, true);
    return onlineOnly
      ? Object.values(players || {}).filter(
          (player) => player && !player.kicked && player.isConnected,
        )
      : Object.values(players || {});
  }

  /**
   *  Immediately removes game data from redis including players and creator
   *  links to game `${clientId}-player` -> `${gameCode}`
   *
   *  @param gameCode {string} - game code
   */
  async clearGame(gameCode: string): Promise<void> {
    const stats: LooseObject = await this.rhgetall(`${gameCode}-game`, true);
    if (!stats) return;
    const pids = Object.keys(stats).filter(
      (key) => key !== 'info' && key !== 'stats' && key !== 'host',
    );
    // creator also
    pids.push(stats.info.userId);
    await Promise.all(
      pids.map((clientId) => {
        return this.redisClient
          .del(`${clientId}-player`)
          .then(() => this.redisClient.del(`${gameCode}-${clientId}`));
      }),
    );
    await this.redisClient.del(`${stats.info.code}-players`);
    await this.redisClient.del(`${stats.info.code}-game`);
    await this.redisClient.del(`${stats.info.code}-pack`);
    await this.redisClient.del(`${stats.info.code}-endpack`);
  }
}

results matching ""

    No results matching ""