tbl/src/game-tbl/game-tbl.service.ts
Game Service responsible for main logic of games running
Properties |
|
Methods |
|
constructor(redisService: RedisService, gameGateWay: GameGateway, jwtService: JwtService, crashReportsService: CrashReportsService)
|
|||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:38
|
|||||||||||||||
constructor
Parameters :
|
Async addNewQuestionsToRound | ||||||||||||
addNewQuestionsToRound(gameCode: string, questions: AbstractRoundQuestion[])
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:874
|
||||||||||||
Adds more questions to currect round @param gameCode {string} - game code @param questions {AbstractRoundQuestion[]} - questions to add
Parameters :
Returns :
Promise<void>
|
Async checkAndUpdateEmptyPlayerRounds | ||||||||||||
checkAndUpdateEmptyPlayerRounds(gameCode: string, currentRound: number)
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:541
|
||||||||||||
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 :
Returns :
Promise<void>
|
Async clientGame | ||||||||
clientGame(clientId: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:77
|
||||||||
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 :
Returns :
Promise<string | null>
|
Async clientGameInfo |
clientGameInfo(clientId: string, gameCode: string)
|
Defined in tbl/src/game-tbl/game-tbl.service.ts:90
|
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
Returns :
Promise<RedisGame>
|
Async clientIsReady |
clientIsReady(gameCode: string, clientId: string)
|
Defined in tbl/src/game-tbl/game-tbl.service.ts:58
|
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
Returns :
Promise<boolean>
|
Async collectGameInfo | |||||||||||||||
collectGameInfo(gameCode: string, onlineOnly)
|
|||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:105
|
|||||||||||||||
Collects all game information including players and stats @param gameCode {string} - game code @return {RedisGame | null} - game data or null if not found
Parameters :
Returns :
Promise<RedisGame>
|
Async findGame | ||||||
findGame(gameCode: string)
|
||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:128
|
||||||
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
Parameters :
Returns :
Promise<RedisGame | null>
game data |
Async gameHostInfo | ||||||||
gameHostInfo(gameCode: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:273
|
||||||||
Get game host info @param gameCode {string} - game code @return {RedisHostInfo} - host info
Parameters :
Returns :
Promise<RedisHostInfo>
|
Async gameSavedToDB | ||||||||||||
gameSavedToDB(redisGameCode: string, mongoGameCode: string)
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:680
|
||||||||||||
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 :
Returns :
Promise<void>
|
Async getAllPlayers | ||||||
getAllPlayers(gameCode: string)
|
||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:346
|
||||||
Game players - list of all players - connected, disconnected and kicked @param gameCode @return {RedisPlayerInfo[]} - list of game players
Parameters :
Returns :
Promise<RedisPlayerInfo[]>
|
Async getGameLeaderboard | |||||||||||||||
getGameLeaderboard(gameCode: string, questions)
|
|||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:603
|
|||||||||||||||
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 :
Returns :
Promise<AbstractLeaderboardRecord[]>
|
Async getGamePlayerInfo | ||||||||||||
getGamePlayerInfo(gameCode: string, clientId: string)
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:358
|
||||||||||||
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 :
Returns :
Promise<RedisPlayerInfo>
|
Async getTotalScoreOfEachPlayer | ||||||||
getTotalScoreOfEachPlayer(gameCode: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:702
|
||||||||
Gets game total scores @param gameCode {string} - game code (redis or mongo) @return {number[]} - array of game players scores
Parameters :
Returns :
Promise<number[]>
|
Async hostLeft | ||||||||
hostLeft(gameCode: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:316
|
||||||||
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 :
Returns :
Promise<void>
|
Async hostPrepareRound | ||||||||||||
hostPrepareRound(gameCode: string, questions: AbstractRoundQuestion[])
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:374
|
||||||||||||
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 :
Returns :
Promise<RedisGameRound>
|
Async hostStartRound | ||||||||
hostStartRound(gameCode: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:417
|
||||||||
Host start prepared round (last one, should be not started) @param gameCode {string} - game code @return {RedisGameRound} - started round info
Parameters :
Returns :
Promise<RedisGameStats>
|
Async playerConnected | ||||||||||||
playerConnected(clientId: string, gameCode: string)
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:158
|
||||||||||||
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 :
Returns :
Promise<LooseObject | null>
|
Async playerDisconnected | ||||||||||||
playerDisconnected(clientId: string, gameCode: string)
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:194
|
||||||||||||
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 :
Returns :
Promise<LooseObject | null>
|
Async playerLeave | ||||||||||||||||
playerLeave(clientId: string, gameCode: string, isHost: boolean)
|
||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:285
|
||||||||||||||||
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 :
Returns :
Promise<boolean>
|
Async playerSelectedAvatar | ||||||||||||||||
playerSelectedAvatar(gameCode: string, clientId: string, avatar: string)
|
||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:776
|
||||||||||||||||
Player select avatar
Parameters :
Returns :
Promise<void>
|
Async playerSelectedBackground | ||||||||||||||||
playerSelectedBackground(gameCode: string, clientId: string, background: string)
|
||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:895
|
||||||||||||||||
Player select background
Parameters :
Returns :
Promise<void>
|
Async playerSelectedCustomAvatar | ||||||||||||||||
playerSelectedCustomAvatar(gameCode: string, clientId: string, customAvatar: literal type)
|
||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:799
|
||||||||||||||||
Player select custom avatar
Parameters :
Returns :
Promise<void>
|
Async playerSelectedFrame | ||||||||||||||||
playerSelectedFrame(gameCode: string, clientId: string, frame: string)
|
||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:918
|
||||||||||||||||
Player select frame
Parameters :
Returns :
Promise<void>
|
Async playerStats | ||||||||||||
playerStats(clientId: string, gameCode: string)
|
||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:331
|
||||||||||||
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 :
Returns :
Promise<RedisPlayerStats>
|
Async removePlayer | |||||||||||||||
removePlayer(clientId: string, kick)
|
|||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:141
|
|||||||||||||||
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 :
Returns :
Promise<string | null>
redis game code players was removed or null |
Async saveGameToDB | ||||||||
saveGameToDB(redisCode: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:821
|
||||||||
Save game to mongo
Parameters :
Returns :
Promise<string>
|
Async savePlayerAnswer | ||||||||||||||||||||
savePlayerAnswer(gameCode: string, clientId: string, data: null)
|
||||||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:467
|
||||||||||||||||||||
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 :
Returns :
Promise<RedisPlayerStats | null>
|
Async setCurrentRoundFinished | ||||||||
setCurrentRoundFinished(gameCode: string)
|
||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:569
|
||||||||
Marks current round as finished @param gameCode {string} redis game code @return {boolean} game is finished (it was last round) or not
Parameters :
Returns :
Promise<boolean>
game is finished (it was last round) or not |
sortBoosterPointsDesc | |||||||||
sortBoosterPointsDesc(a: LooseObject, b: LooseObject)
|
|||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:723
|
|||||||||
Desc sorting helper
Parameters :
Returns :
number
|
sortPointsDesc | |||||||||
sortPointsDesc(a: LooseObject, b: LooseObject)
|
|||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:710
|
|||||||||
Sorting helper
Parameters :
Returns :
number
|
Async updateClassCodeForTheGame | ||||||||||||||||
updateClassCodeForTheGame(gameCode: string, newClassCode: string, requireLogin: boolean)
|
||||||||||||||||
Defined in tbl/src/game-tbl/game-tbl.service.ts:743
|
||||||||||||||||
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 :
Returns :
Promise<boolean>
|
Private Readonly logger |
Default value : new Logger(GameService.name)
|
Defined in tbl/src/game-tbl/game-tbl.service.ts:38
|
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);
}
}