diff --git a/client/src/app/pages/game/game-page/game-page.component.html b/client/src/app/pages/game/game-page/game-page.component.html index 2f8a1777cb67fa1ca9d81afd6f2d5bbc27af96bd..c17a68c9019526383bb588bc61b1070869d62c92 100644 --- a/client/src/app/pages/game/game-page/game-page.component.html +++ b/client/src/app/pages/game/game-page/game-page.component.html @@ -3,7 +3,7 @@ <div id="game-info"> <app-radial-timer size="65"></app-radial-timer> <div class="score-board"> - <span class="name">{{ player.name }}</span> + <span class="name">{{ player.username }}</span> <span class="score">{{ player.score }}pts</span> </div> <app-chat-box id="chat-box"></app-chat-box> diff --git a/client/src/app/pages/game/game-page/game-page.component.ts b/client/src/app/pages/game/game-page/game-page.component.ts index 41049c155f7ef4d1c0d201e2ddc2159428f19ea7..9ac58e64d3d5d2f0730c45a4ddcb0025d411160f 100644 --- a/client/src/app/pages/game/game-page/game-page.component.ts +++ b/client/src/app/pages/game/game-page/game-page.component.ts @@ -18,7 +18,7 @@ export const DEFAULT_POINTS = 8; styleUrls: ['./game-page.component.scss'], }) export class GamePageComponent implements OnInit { - player: Player = { name: 'Garry', score: 0 }; + player: Player = { username: 'Gary', score: 0 }; constructor(private playerService: PlayerService) {} ngOnInit(): void { diff --git a/common/events/game.events.ts b/common/events/game.events.ts index a8bafeab4b9a2870ec319604624b880c4ef96296..93af3d60611001e3075e7dd4418f30aa9190fb53 100644 --- a/common/events/game.events.ts +++ b/common/events/game.events.ts @@ -2,11 +2,16 @@ export enum GameServerEvent { Exception = 'exception', RedirectToWaitingRoom = 'redirect-to-waiting-room', StartDemo = 'start-demo', + NextQuestion = 'next-question', + TimesUp = 'times-up', + Results = 'results', + EndGame = 'end-game', } export enum GameClientEvent { Create = 'create', Demo = 'demo', + Answer = 'answer', } export type GameEvent = GameServerEvent | GameClientEvent; diff --git a/common/player.ts b/common/player.ts index 55fd72aaf6189da4638f63095c8cea44e1588ccd..aad64fd32b043bb769b2a7daf6dfa5e199aa45f4 100644 --- a/common/player.ts +++ b/common/player.ts @@ -1,4 +1,4 @@ export type Player = { - name: string; + username: string; score: number; }; diff --git a/common/question.ts b/common/question.ts index 2dc0d1eda83b97062d230062e3192f325d1af6f2..3f8f22b7e1bdf09f1ecde106694068dd32aa1925 100644 --- a/common/question.ts +++ b/common/question.ts @@ -33,11 +33,16 @@ export type Qcm = ConcreteQuestionType<CreateQcm>; export type Qrl = ConcreteQuestionType<CreateQrl>; export type Question = Qcm | Qrl; -export type PlayableQcm = Omit<Qcm, 'choices'>; -export type PlayableQrl = Qrl; -export type PlayableQuestion = PlayableQcm | PlayableQrl; - type StandaloneQuestionType<T extends Question> = T & { lastModification: Date }; export type StandaloneQcm = StandaloneQuestionType<Qcm>; export type StandaloneQrl = StandaloneQuestionType<Qrl>; export type StandaloneQuestion = StandaloneQcm | StandaloneQrl; + +export type PlayableQcm = Omit<Qcm, 'choices'>; +export type PlayableQrl = Qrl; +export type PlayableQuestion = PlayableQcm | PlayableQrl; + +type GameQuestionType<T extends PlayableQuestion> = T & { index: number }; +export type GameQcmQuestion = GameQuestionType<PlayableQuestion> & { choices: string[] }; +export type GameQrlQuestion = GameQuestionType<PlayableQuestion>; +export type GameQuestion = GameQcmQuestion | GameQrlQuestion; diff --git a/common/results.ts b/common/results.ts new file mode 100644 index 0000000000000000000000000000000000000000..985f17edc613baa82824a498bd04c8168fb2f8ff --- /dev/null +++ b/common/results.ts @@ -0,0 +1,17 @@ +import { Player } from './player'; +import { QuestionType } from './question'; + +interface BaseResults { + players: { [id: string]: Player }; +} + +export interface QcmResults extends BaseResults { + type: QuestionType.Qcm; + solution: boolean[]; +} + +export interface QrlResults extends BaseResults { + type: QuestionType.Qrl; +} + +export type Results = QcmResults | QrlResults; diff --git a/server/app/game/classes/room.class.constants.ts b/server/app/game/classes/room.class.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..4233811f0aadb13b15316eb8164dcd436ff9e9ea --- /dev/null +++ b/server/app/game/classes/room.class.constants.ts @@ -0,0 +1,3 @@ +export const BONUS_FACTOR = 1.2; +export const DELAY_BETWEEN_QUESTIONS_MS = 3000; +export const S_TO_MS = 1000; diff --git a/server/app/game/classes/room.class.spec.ts b/server/app/game/classes/room.class.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e1dfdf922e47b3f51b7b480075eacc498591ef1 --- /dev/null +++ b/server/app/game/classes/room.class.spec.ts @@ -0,0 +1,275 @@ +import { Game } from '@common/game'; +import { Room } from './room.class'; +import { Quiz } from '@app/quiz/schemas/quiz.schema'; +import { Server, Socket, BroadcastOperator } from 'socket.io'; +import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; +import * as sinon from 'sinon'; +import { QuestionType } from '@common/question'; +import { WsException } from '@nestjs/websockets'; + +jest.useFakeTimers(); + +describe('Room', () => { + let server: sinon.SinonStubbedInstance<Server>; + let serverRoom: sinon.SinonStubbedInstance<Socket>; + + beforeEach(() => { + server = sinon.createStubInstance<Server>(Server); + serverRoom = sinon.createStubInstance<Socket>(Socket); + server.to.callsFake(() => serverRoom as unknown as BroadcastOperator<DefaultEventsMap, unknown>); + }); + + it('should be defined', () => { + const room = new Room({} as Game, {} as Quiz, server); + expect(room).toBeDefined(); + }); + + describe('addPlayer', () => { + it('should add a player', () => { + const room = new Room({} as Game, {} as Quiz, server); + + const client = sinon.createStubInstance(Socket); + + room.addPlayer(client, 'player1'); + + expect(client.join.calledOnceWithExactly(room.id)).toBe(true); + + const players = room['players']; + expect(players.size).toBe(1); + expect(players.get(client.id)).toEqual({ username: 'player1', score: 0 }); + }); + }); + + describe('start', () => { + it('should start the game after 3 seconds', () => { + const room = new Room({} as Game, {} as Quiz, server); + const nextSpy = sinon.stub(room, 'next'); + + room.start(); + expect(nextSpy.calledOnce).toBe(false); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + jest.advanceTimersByTime(3000); + + expect(nextSpy.calledOnce).toBe(true); + }); + }); + + describe('next', () => { + let room: Room; + let timesUpStub: sinon.SinonStub; + + beforeEach(() => { + room = new Room( + { id: 'gameid' } as Game, + { + questions: [ + { + type: QuestionType.Qcm, + choices: [ + { text: 'choice1', isCorrect: true }, + { text: 'choice2', isCorrect: false }, + ], + }, + ], + } as unknown as Quiz, + server, + ); + room['currentQuestionIndex'] = -1; + + timesUpStub = sinon.stub(room, 'timesUp'); + }); + + it('should end the game if there are no more questions', () => { + room['currentQuestionIndex'] = 0; + + const endSpy = sinon.stub(room, 'end'); + + room.next(); + expect(endSpy.calledOnce).toBe(true); + }); + + it('should emit the next question', () => { + room['quiz'].questions[0] = { type: QuestionType.Qrl, _id: '', text: '', points: 0 }; + room.next(); + expect(server.to.calledOnceWith('gameid')).toBe(true); + + expect(serverRoom.emit.calledOnce).toBe(true); + expect(serverRoom.emit.calledOnceWith('next-question')).toBe(true); + const gameQuestion = serverRoom.emit.firstCall.args[1]; + + expect(gameQuestion).toMatchObject({ index: 0, type: QuestionType.Qrl }); + }); + + it('should emit the next QCM question with choices', () => { + room.next(); + expect(server.to.calledOnceWith('gameid')).toBe(true); + + expect(serverRoom.emit.calledOnce).toBe(true); + expect(serverRoom.emit.calledOnceWith('next-question')).toBe(true); + const gameQuestion = serverRoom.emit.firstCall.args[1]; + + expect(gameQuestion).toMatchObject({ index: 0, type: QuestionType.Qcm, choices: ['choice1', 'choice2'] }); + }); + + it('should set a timeout for the answer period', () => { + room['quiz'].duration = 5; + + room.next(); + expect(room['currentQuestionTimeout']).toBeDefined(); + expect(timesUpStub.called).toBe(false); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + jest.advanceTimersByTime(4500); + expect(timesUpStub.called).toBe(false); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + jest.advanceTimersByTime(500); + expect(timesUpStub.called).toBe(true); + }); + }); + + describe('answer', () => { + let room: Room; + let sendResultsStub: sinon.SinonStub; + + beforeEach(() => { + room = new Room( + {} as Game, + { questions: [{ type: QuestionType.Qcm, points: 100, choices: [{ isCorrect: true }] }] } as unknown as Quiz, + server, + ); + room['currentQuestionIndex'] = 0; + room['players'] = new Map([['playerid', { username: 'player1', score: 0 }]]); + sendResultsStub = sinon.stub(room, 'sendResults'); + }); + + it('should add points to the player if the answer is correct', () => { + room.answer('playerid', [true]); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect(room['players'].get('playerid')?.score).toEqual(120); + }); + + it('should call sendResults after', () => { + room.answer('playerid', [true]); + + expect(sendResultsStub.calledOnce).toBe(true); + }); + + it('should clear the timeout', () => { + const spy = sinon.spy(); + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + room['currentQuestionTimeout'] = setTimeout(() => spy, 1000); + + room.answer('playerid', [false]); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + jest.advanceTimersByTime(1000); + expect(spy.called).toBe(false); + }); + + it('should throw if the player is not in the room', () => { + expect(() => room.answer('unknownplayerid', [false])).toThrowError(WsException); + }); + + it('should throw if the question is not a QCM', () => { + room['quiz'].questions[0].type = QuestionType.Qrl; + expect(() => room.answer('playerid', [false])).toThrowError(WsException); + }); + }); + + describe('timesUp', () => { + it('should emit TimesUp', () => { + const room = new Room({} as Game, {} as Quiz, server); + + room['currentQuestionIndex'] = 0; + + room.timesUp(); + expect(server.to.calledOnceWith(room.id)).toBe(true); + expect(serverRoom.emit.calledOnceWith('times-up')).toBe(true); + }); + }); + + describe('sendResults', () => { + let room: Room; + let nextStub: sinon.SinonStub; + + beforeEach(() => { + room = new Room( + {} as Game, + { + questions: [{ type: QuestionType.Qcm, points: 100, choices: [{ isCorrect: true }, { isCorrect: false }, { isCorrect: true }] }], + } as unknown as Quiz, + server, + ); + room['currentQuestionIndex'] = 0; + room['players'] = new Map([ + ['playerid1', { username: 'player1', score: 10 }], + ['playerid2', { username: 'player2', score: 20 }], + ]); + + nextStub = sinon.stub(room, 'next'); + }); + + it('should emit the solution with a QCM', () => { + room.sendResults(); + + expect(server.to.calledOnceWith(room.id)).toBe(true); + expect(serverRoom.emit.calledOnce).toBe(true); + expect(serverRoom.emit.calledOnceWith('results')).toBe(true); + + const results = serverRoom.emit.firstCall.args[1]; + expect(results).toEqual({ + type: QuestionType.Qcm, + players: { + playerid1: { username: 'player1', score: 10 }, + playerid2: { username: 'player2', score: 20 }, + }, + solution: [true, false, true], + }); + }); + + it('should only emit the score with a QRL', () => { + room['quiz'].questions[0].type = QuestionType.Qrl; + + room.sendResults(); + + expect(server.to.calledOnceWith(room.id)).toBe(true); + expect(serverRoom.emit.calledOnce).toBe(true); + expect(serverRoom.emit.calledOnceWith('results')).toBe(true); + + const results = serverRoom.emit.firstCall.args[1]; + expect(results).toEqual({ + type: QuestionType.Qrl, + players: { + playerid1: { username: 'player1', score: 10 }, + playerid2: { username: 'player2', score: 20 }, + }, + }); + }); + + it('should call next after 3 seconds', () => { + room.sendResults(); + expect(nextStub.called).toBe(false); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + jest.advanceTimersByTime(2500); + expect(nextStub.called).toBe(false); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + jest.advanceTimersByTime(500); + expect(nextStub.calledOnce).toBe(true); + }); + }); + + describe('end', () => { + it('should emit EndGame', () => { + const room = new Room({} as Game, {} as Quiz, server); + + room.end(); + expect(server.to.calledOnceWith(room.id)).toBe(true); + expect(serverRoom.emit.calledOnceWith('end-game')).toBe(true); + }); + }); +}); diff --git a/server/app/game/classes/room.class.ts b/server/app/game/classes/room.class.ts new file mode 100644 index 0000000000000000000000000000000000000000..13ffe34c2a440c3cca7bc9cd4608d42ec9074c81 --- /dev/null +++ b/server/app/game/classes/room.class.ts @@ -0,0 +1,132 @@ +import { Quiz } from '@common/quiz'; +import { Socket, Server } from 'socket.io'; +import { Question, QuestionType, GameQuestion } from '@common/question'; +import { Game } from '@common/game'; +import { Results } from '@common/results'; +import { Player } from '@common/player'; +import { GameServerEvent } from '@common/events/game.events'; +import { DELAY_BETWEEN_QUESTIONS_MS, S_TO_MS, BONUS_FACTOR } from './room.class.constants'; +import { WsException } from '@nestjs/websockets'; + +export class Room { + readonly id: string; + + private players: Map<string, Player> = new Map(); + + private currentQuestionIndex: number; + private currentQuestionTimeout?: NodeJS.Timeout; + + constructor( + readonly game: Game, + private readonly quiz: Quiz, + private readonly server: Server, + ) { + this.id = game.id; + } + + addPlayer(client: Socket, username: string) { + client.join(this.game.id); + + this.players.set(client.id, { username, score: 0 }); + } + + start() { + this.currentQuestionIndex = -1; + + setTimeout(() => { + this.next(); + }, DELAY_BETWEEN_QUESTIONS_MS); + } + + next() { + if (this.currentQuestionIndex === this.quiz.questions.length - 1) { + this.end(); + return; + } + + const question: Question = this.quiz.questions[++this.currentQuestionIndex]; + + let gameQuestion: GameQuestion; + + if (question.type === QuestionType.Qcm) { + gameQuestion = { + ...question, + index: this.currentQuestionIndex, + type: question.type, + choices: question.choices.map((choice) => choice.text), + }; + } else { + gameQuestion = { + ...question, + index: this.currentQuestionIndex, + type: question.type, + }; + } + + this.toAll().emit(GameServerEvent.NextQuestion, gameQuestion); + + this.currentQuestionTimeout = setTimeout(() => { + this.timesUp(); + }, this.quiz.duration * S_TO_MS); + } + + answer(userId: string, answers: boolean[]) { + // FIXME: refactor when more than one player can answer + clearTimeout(this.currentQuestionTimeout); + + const player = this.players.get(userId); + + if (!player) { + throw new WsException('Player not found'); + } + + const question = this.quiz.questions[this.currentQuestionIndex]; + + if (question.type !== QuestionType.Qcm) { + throw new WsException('Not implemented'); + } + + const correct = question.choices.every((choice, index) => choice.isCorrect === answers[index]); + + if (correct) { + player.score += question.points * BONUS_FACTOR; + } + + this.sendResults(); + } + + timesUp() { + this.toAll().emit(GameServerEvent.TimesUp); + } + + sendResults() { + const question = this.quiz.questions[this.currentQuestionIndex]; + + const players = Object.fromEntries(this.players); + const results: Results = + question.type === QuestionType.Qcm + ? { + type: question.type, + players, + solution: question.choices.map((choice) => choice.isCorrect), + } + : { + type: question.type, + players, + }; + + this.toAll().emit(GameServerEvent.Results, results); + + setTimeout(() => { + this.next(); + }, DELAY_BETWEEN_QUESTIONS_MS); + } + + end() { + this.toAll().emit(GameServerEvent.EndGame); + } + + private toAll() { + return this.server.to(this.game.id); + } +} diff --git a/server/app/game/game-room.service.spec.ts b/server/app/game/game-room.service.spec.ts index 4a2bbb1621cb80fa5a232d5d124c2976fe1354b5..6736e9bdac3c965f0ff3eecdf1f2cee13180b0e0 100644 --- a/server/app/game/game-room.service.spec.ts +++ b/server/app/game/game-room.service.spec.ts @@ -1,13 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; import { GameRoomService } from './game-room.service'; import { Quiz, QuizDocument } from '@app/quiz/schemas/quiz.schema'; -import { Game, GameType } from '@common/game'; -import { GameWithQuiz } from './interfaces/game.interface'; +import { GameType } from '@common/game'; +import * as sinon from 'sinon'; +import { Server } from 'socket.io'; +import * as roomModule from './classes/room.class'; + +const roomConstructor = sinon.stub(roomModule, 'Room'); describe('GameRoomService', () => { let service: GameRoomService; beforeEach(async () => { + roomConstructor.reset(); + const module: TestingModule = await Test.createTestingModule({ providers: [GameRoomService], }).compile(); @@ -20,24 +26,29 @@ describe('GameRoomService', () => { }); describe('create', () => { + let server: sinon.SinonStubbedInstance<Server>; let plainQuiz: Quiz; let quiz: QuizDocument; - - let game: Game; + let room: roomModule.Room; beforeEach(() => { + server = sinon.createStubInstance<Server>(Server); plainQuiz = { title: 'foo', duration: 10 } as Quiz; - quiz = { title: plainQuiz.title, duration: plainQuiz.duration, toObject: () => quiz, } as unknown as QuizDocument; - - game = service.create(quiz, GameType.Normal); + room = service.create(server, quiz, GameType.Normal); }); it('should create a room', () => { + expect(roomConstructor.calledOnce).toBe(true); + + const args = roomConstructor.getCall(0).args; + + const game = args[0]; + expect(game.id).toContain('game-'); // eslint-disable-next-line @typescript-eslint/no-magic-numbers expect(game.pin).toHaveLength(4); @@ -45,30 +56,49 @@ describe('GameRoomService', () => { expect(game.title).toBe('foo'); // eslint-disable-next-line @typescript-eslint/no-magic-numbers expect(game.duration).toBe(10); + + expect(args[1]).toMatchObject(plainQuiz); + expect(args[2]).toBe(server); }); it('should save the room', () => { - expect(service['rooms'].get(game.id)).toMatchObject({ - quiz: plainQuiz, - type: GameType.Normal, - title: 'foo', - duration: 10, - }); + const game = roomConstructor.getCall(0).args[0]; + expect(service['rooms'].get(game.id)).toBe(room); }); it('should create two different rooms for the same quiz', () => { - const game1 = service.create(quiz, GameType.Normal); - const game2 = service.create(quiz, GameType.Normal); + service.create(server, quiz, GameType.Normal); + + const game1 = roomConstructor.getCall(0).args[0]; + const game2 = roomConstructor.getCall(1).args[0]; expect(game1.id).not.toEqual(game2.id); }); }); + describe('get', () => { + let room: roomModule.Room; + + beforeEach(() => { + room = {} as roomModule.Room; + + service['rooms'].set('foo', room); + }); + + it('should get a room', () => { + expect(service.get(new Set(['bar', 'foo']))).toBe(room); + }); + + it('should return undefined if the room does not exist', () => { + expect(service.get(new Set(['bar']))).toBeUndefined(); + }); + }); + describe('delete', () => { it('should delete a room', () => { const roomId = 'foo'; - service['rooms'].set(roomId, {} as GameWithQuiz); + service['rooms'].set(roomId, {} as roomModule.Room); service.delete(roomId); diff --git a/server/app/game/game-room.service.ts b/server/app/game/game-room.service.ts index 2832498b88b8ff2b2f0152fc4f342fdca37a5ca0..9880647681804181a01e2b335f9944210855ab04 100644 --- a/server/app/game/game-room.service.ts +++ b/server/app/game/game-room.service.ts @@ -1,27 +1,38 @@ import { QuizDocument } from '@app/quiz/schemas/quiz.schema'; -import { Game, GameType } from '@common/game'; +import { GameType } from '@common/game'; import { GAME_ROOM_ID_END, GAME_ROOM_ID_START, GAME_ROOM_PREFIX } from './game-room.service.constants'; import { Injectable } from '@nestjs/common'; -import { GameWithQuiz } from './interfaces/game.interface'; +import { Room } from './classes/room.class'; +import { Server } from 'socket.io'; @Injectable() export class GameRoomService { - private rooms = new Map<string, GameWithQuiz>(); + private rooms = new Map<string, Room>(); - create(quiz: QuizDocument, type: GameType): Game { + create(server: Server, quiz: QuizDocument, type: GameType): Room { const pin = this.generatePin(); const roomId = this.buildId(pin); - const game: Game = { + const game = { id: roomId, pin, type, title: quiz.title, duration: quiz.duration, }; - this.rooms.set(roomId, { ...game, quiz: quiz.toObject() }); - return game; + const room = new Room(game, quiz.toObject(), server); + this.rooms.set(roomId, room); + + return room; + } + + get(rooms: Set<string>): Room | undefined { + for (const roomId of rooms) { + if (this.rooms.has(roomId)) { + return this.rooms.get(roomId); + } + } } delete(roomId: string): void { diff --git a/server/app/game/game.gateway.constants.ts b/server/app/game/game.gateway.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b70e02196d69b26b62bd8347179978c91e29069 --- /dev/null +++ b/server/app/game/game.gateway.constants.ts @@ -0,0 +1 @@ +export const GAME_CREATOR_USERNAME = 'Organisateur'; diff --git a/server/app/game/game.gateway.spec.ts b/server/app/game/game.gateway.spec.ts index 70005bf6c478ac04ea4f11b2b2be067adcdd6b89..26e75469dbf59000825c02714f9e014f5aa64b21 100644 --- a/server/app/game/game.gateway.spec.ts +++ b/server/app/game/game.gateway.spec.ts @@ -1,19 +1,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { GameGateway } from './game.gateway'; import * as sinon from 'sinon'; -import { QuizService } from '@app/quiz/quiz.service'; import { Socket, Server, BroadcastOperator } from 'socket.io'; -import { Game, GameType } from '@common/game'; +import { GameType } from '@common/game'; import { QuizDocument } from '@app/quiz/schemas/quiz.schema'; import { Logger } from '@nestjs/common'; import { GameRoomService } from './game-room.service'; import { DefaultEventsMap } from 'socket.io/dist/typed-events'; import { Writeable } from '@app/core/types/testing'; +import { Room } from './classes/room.class'; +import { AvailableQuizPipe } from './pipes/available-quiz.pipe'; +import { WsException } from '@nestjs/websockets'; describe('GameGateway', () => { let gateway: GameGateway; let gameRoomService: sinon.SinonStubbedInstance<GameRoomService>; - let quizService: sinon.SinonStubbedInstance<QuizService>; let logger: sinon.SinonStubbedInstance<Logger>; let socket: sinon.SinonStubbedInstance<Socket>; @@ -21,7 +22,6 @@ describe('GameGateway', () => { beforeEach(async () => { gameRoomService = sinon.createStubInstance<GameRoomService>(GameRoomService); - quizService = sinon.createStubInstance<QuizService>(QuizService); logger = sinon.createStubInstance<Logger>(Logger); socket = sinon.createStubInstance<Socket>(Socket); @@ -34,12 +34,11 @@ describe('GameGateway', () => { provide: GameRoomService, useValue: gameRoomService, }, - { - provide: QuizService, - useValue: quizService, - }, ], - }).compile(); + }) + .overridePipe(AvailableQuizPipe) + .useValue({ transform: (quiz: QuizDocument) => quiz }) + .compile(); gateway = module.get<GameGateway>(GameGateway); gateway['logger'] = logger; @@ -51,46 +50,69 @@ describe('GameGateway', () => { }); describe('create', () => { - it('should create a normal room and join the client', () => { + let room: sinon.SinonStubbedInstance<Room>; + + beforeEach(() => { const quiz = { id: 'foo' } as QuizDocument; - const roomId = 'bar'; - gameRoomService.create.withArgs(quiz, GameType.Normal).returns({ id: roomId } as Game); + room = sinon.createStubInstance(Room); + (room as Writeable<Room>).id = 'bar'; + gameRoomService.create.withArgs(server, quiz, GameType.Normal).returns(room); gateway.create(quiz, socket); + }); - expect(socket.join.calledWith(roomId)).toBe(true); + it('should create a room and join the client', () => { + expect(gameRoomService.create.calledOnce).toBe(true); + expect(room.addPlayer.calledWith(socket, 'Organisateur')).toBe(true); }); it('should redirect the client to the waiting room', () => { - const quiz = { id: 'foo' } as QuizDocument; - const game = { id: 'bar' } as Game; - gameRoomService.create.withArgs(quiz, GameType.Normal).returns(game); - - gateway.create(quiz, socket); - - expect(socket.emit.calledWith('redirect-to-waiting-room', game)).toBe(true); + expect(socket.emit.calledWith('redirect-to-waiting-room', room.game)).toBe(true); }); }); describe('demo', () => { - it('should create a demo room and join the client', () => { + let room: sinon.SinonStubbedInstance<Room>; + + beforeEach(() => { const quiz = { id: 'foo' } as QuizDocument; - const roomId = 'bar'; - gameRoomService.create.withArgs(quiz, GameType.Demo).returns({ id: roomId } as Game); + room = sinon.createStubInstance(Room); + (room as Writeable<Room>).id = 'bar'; + gameRoomService.create.withArgs(server, quiz, GameType.Demo).returns(room); gateway.demo(quiz, socket); + }); - expect(socket.join.calledWith(roomId)).toBe(true); + it('should create a room and join the client', () => { + expect(gameRoomService.create.calledOnce).toBe(true); + expect(room.addPlayer.calledWith(socket, 'Organisateur')).toBe(true); }); it('should start the demo for the client', () => { - const quiz = { id: 'foo' } as QuizDocument; - const game = { id: 'bar' } as Game; - gameRoomService.create.withArgs(quiz, GameType.Demo).returns(game); + expect(socket.emit.calledWith('start-demo', room.game)).toBe(true); + }); - gateway.demo(quiz, socket); + it('should start the room', () => { + expect(room.start.calledOnce).toBe(true); + }); + }); + + describe('answer', () => { + it('should get the room and answer', () => { + const room = sinon.createStubInstance(Room); + sinon.stub(socket, 'rooms').value(new Set(['foo'])); + gameRoomService.get.withArgs(socket.rooms).returns(room); + + gateway.answer([true, false], socket); + + expect(room.answer.calledWith(socket.id, [true, false])).toBe(true); + }); + + it('should throw if the room is not found', () => { + sinon.stub(socket, 'rooms').value(new Set(['foo'])); + gameRoomService.get.withArgs(socket.rooms).returns(undefined); - expect(socket.emit.calledWith('start-demo', game)).toBe(true); + expect(() => gateway.answer([true, false], socket)).toThrowError(WsException); }); }); diff --git a/server/app/game/game.gateway.ts b/server/app/game/game.gateway.ts index 5191d9ba85c59dbdbd6f4d9e57ac92b9b2fa5203..158e40b395cde9839e65d09c671d213fc403fef6 100644 --- a/server/app/game/game.gateway.ts +++ b/server/app/game/game.gateway.ts @@ -7,6 +7,7 @@ import { SubscribeMessage, WebSocketGateway, WebSocketServer, + WsException, } from '@nestjs/websockets'; import { Socket, Server } from 'socket.io'; import { GameClientEvent, GameServerEvent } from '@common/events/game.events'; @@ -14,6 +15,7 @@ import { AvailableQuizPipe } from './pipes/available-quiz.pipe'; import { QuizDocument } from '@app/quiz/schemas/quiz.schema'; import { GameType } from '@common/game'; import { GameRoomService } from './game-room.service'; +import { GAME_CREATOR_USERNAME } from './game.gateway.constants'; @WebSocketGateway({ cors: true, namespace: 'game' }) export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { @@ -25,20 +27,33 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { @SubscribeMessage(GameClientEvent.Create) create(@MessageBody(AvailableQuizPipe) quiz: QuizDocument, @ConnectedSocket() client: Socket) { - const game = this.gameRoomService.create(quiz, GameType.Normal); - client.join(game.id); - this.logger.log(`Game with quiz ${quiz.id} created: ${game.id}`); + const room = this.gameRoomService.create(this.server, quiz, GameType.Normal); + room.addPlayer(client, GAME_CREATOR_USERNAME); - client.emit(GameServerEvent.RedirectToWaitingRoom, game); + this.logger.log(`Game with quiz ${quiz.id} created: ${room.id}`); + + client.emit(GameServerEvent.RedirectToWaitingRoom, room.game, GAME_CREATOR_USERNAME); } @SubscribeMessage(GameClientEvent.Demo) demo(@MessageBody(AvailableQuizPipe) quiz: QuizDocument, @ConnectedSocket() client: Socket) { - const game = this.gameRoomService.create(quiz, GameType.Demo); - client.join(game.id); - this.logger.log(`Demo game with quiz ${quiz.id} created: ${game.id}`); + const room = this.gameRoomService.create(this.server, quiz, GameType.Demo); + room.addPlayer(client, GAME_CREATOR_USERNAME); + + this.logger.log(`Demo game with quiz ${quiz.id} created: ${room.id}`); + + client.emit(GameServerEvent.StartDemo, room.game, GAME_CREATOR_USERNAME); + room.start(); + } + + @SubscribeMessage(GameClientEvent.Answer) + answer(@MessageBody() answers: boolean[], @ConnectedSocket() client: Socket) { + const room = this.gameRoomService.get(client.rooms); + if (!room) { + throw new WsException('Room not found'); + } - client.emit(GameServerEvent.StartDemo, game); + room.answer(client.id, answers); } handleConnection(client: Socket) { diff --git a/server/app/game/interfaces/game.interface.ts b/server/app/game/interfaces/game.interface.ts deleted file mode 100644 index 4db0f7a901712b28048a4581428f4c84f8b1a179..0000000000000000000000000000000000000000 --- a/server/app/game/interfaces/game.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Quiz } from '@app/quiz/schemas/quiz.schema'; -import { Game } from '@common/game'; - -export interface GameWithQuiz extends Game { - quiz: Quiz; -}