diff --git a/src/__tests__/car.test.js b/src/__tests__/car.test.js index f7f9b94..dc8efd4 100644 --- a/src/__tests__/car.test.js +++ b/src/__tests__/car.test.js @@ -1,36 +1,30 @@ -import { describe, test, expect } from 'vitest' -import Car from '../domains/Car.js' -import { carValidations } from '../validations/car.js' +import { describe, test, expect } from 'vitest'; +import Car from '../domains/Car.js'; +import { carValidations } from '../validations/car.js'; -const spacedCarNameCases = [' ab', 'a b', ' ab ', 'a b '] -const invalidCarNameCases = ['', 'abcdef', ' '] +describe('Car', () => { + test('올바른 이름과 초기 위치(0)으로 자동차를 초기화한다.', () => { + const car = new Car('테스트'); + expect(car.name).toBe('테스트'); + expect(car.position).toBe(0); + }); -describe('Car Test', () => { - test.each(invalidCarNameCases)( - '자동차 이름은 공백 제외 1자 이상 5자 이하여야 한다.', - name => { - expect(() => new Car(name)).toThrow( - carValidations.carNameLength.errorMessage - ) - } - ) + test('자동차를 올바르게 이동시킨다.', () => { + const car = new Car('테스트'); + car.move(3); + expect(car.position).toBe(3); + car.move(-1); + expect(car.position).toBe(2); + car.move(-3); + expect(car.position).toBe(0); + }); - test.each(spacedCarNameCases)( - '생성된 자동차 이름에는 공백이 없어야 한다.', - name => { - const car = new Car(name) - expect(car.name).toBe('ab') - } - ) - - test('생성된 자동차의 위치는 0으로 초기화되어야 한다.', () => { - const car = new Car('car') - expect(car.position).toBe(0) - }) - - test('move 시 자동차의 위치가 1 증가해야 한다.', () => { - const car = new Car('car') - car.move() - expect(car.position).toBe(1) - }) -}) + test('유효하지 않은 이름에 대해 오류를 발생시킨다.', () => { + expect(() => new Car(' ')).toThrow( + carValidations.carNameLength.errorMessage, + ); + expect(() => new Car('기이이인이름')).toThrow( + carValidations.carNameLength.errorMessage, + ); + }); +}); diff --git a/src/__tests__/game.test.js b/src/__tests__/game.test.js deleted file mode 100644 index 2e63fc6..0000000 --- a/src/__tests__/game.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, test, expect, beforeEach, vi } from 'vitest' -import Game from '../domains/Game.js' - -const testMaxRound = 4 - -describe('Game Test', () => { - let display - let game - - beforeEach(() => { - display = { - print: vi.fn(), - printError: vi.fn(), - } - - game = new Game({ - display, - maxRound: testMaxRound, - config: {}, - }) - }) - - test('eachRound 메소드가 rounds 에 올바르게 바인딩되어야 한다.', () => { - // eachRound 모킹 - game.eachRound = vi.fn() - - // rounds 배열에 바인딩 - game.bindRounds() - - // 설정한 라운드 수 만큼 배열에 들어갔는지 + 실행되었는지 확인 - expect(game.rounds.length).toBe(testMaxRound) - game.rounds.forEach(round => { - round() - }) - expect(game.eachRound).toHaveBeenCalledTimes(testMaxRound) - }) - - test('round(..) 메소드가 rounds 에 올바르게 바인딩되어야 한다.', () => { - // mock 함수 생성 - const play1 = vi.fn() - const play2 = vi.fn() - const notPlay = vi.fn() - - // round 함수 방식의 게임 생성 - class ExtendedGame extends Game { - round1() { - play1() - } - round2() { - play2() - } - anyMethod() { - notPlay() - } - } - const extendedGame = new ExtendedGame({ - display, - maxRound: 2, - config: {}, - }) - - // rounds 배열 에 바인딩 - extendedGame.bindRounds() - - // rounds 배열에 [round1(), round2()] 있는지 비교 - expect(extendedGame.rounds.length).toBe(2) - - extendedGame.rounds.forEach(round => { - round() - }) - - // round1,2 함수 안에서 실행했던 mock 함수의 호출 확인 (round1,2 함수가 제대로 실행되었는가) - expect(play1).toHaveBeenCalled() - expect(play2).toHaveBeenCalled() - expect(notPlay).not.toHaveBeenCalled() - }) - - test('play 시 setup, eachRound, finish 가 순서에 맞게 동작해야 한다.', async () => { - // setup, eachRound, finish 모킹 - game.setup = vi.fn() - game.eachRound = vi.fn() - game.finish = vi.fn() - - game.bindRounds() - await game.play() - - // setup, eachRound, finish 호출 확인 - expect(game.setup).toHaveBeenCalled() - expect(game.eachRound).toHaveBeenCalledTimes(testMaxRound) - expect(game.finish).toHaveBeenCalled() - - // currentRound 변경 확인 - expect(game.currentRound).toBe(testMaxRound) - - // setup - eachRound - finish 순서로 호출되었는지 확인 - expect(game.setup.mock.invocationCallOrder[0]).toBeLessThan( - game.eachRound.mock.invocationCallOrder[0] - ) - expect(game.finish.mock.invocationCallOrder[0]).toBeGreaterThan( - game.eachRound.mock.invocationCallOrder[0] - ) - }) - - test('setup 도중 에러가 발생하면 다시 play 한다.', async () => { - const setUpError = new Error('setup error') - - // setup 모킹 / 첫번째 호출 시에는 에러, 두번째 호출 시에는 성공 - game.setup = vi - .fn() - .mockImplementationOnce(() => { - throw setUpError - }) - .mockImplementationOnce(() => Promise.resolve()) - - game.finish = vi.fn() - await game.play() - - // 에러 출력 여부 확인 / 다시 play 되었는지 확인 - expect(display.printError).toHaveBeenCalledWith(setUpError) - expect(game.setup).toHaveBeenCalledTimes(2) - expect(game.finish).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/__tests__/racingGame.test.js b/src/__tests__/racingGame.test.js index 0d6f56b..92f81c6 100644 --- a/src/__tests__/racingGame.test.js +++ b/src/__tests__/racingGame.test.js @@ -1,116 +1,109 @@ -import { describe, test, expect, beforeEach, vi } from 'vitest' -import RacingGame from '../domains/RacingGame.js' -import Car from '../domains/Car.js' -import * as randomNumberModule from '../utils/getRandomNumber.js' -import { racingValidations } from '../validations/racing.js' - -const testRacingConfig = { - min: 0, - max: 100, - threshold: 50, -} - -describe('RacingGame Test', () => { - let display - let racingGame +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import RacingGame from '../domains/racingGame/RacingGame.js'; +import { racingValidations } from '../validations/racing.js'; + +const mockMiniGame = { + PvC: vi.fn().mockResolvedValue({ + score: 1, + log: { player: 1, computer: 0, result: 'win' }, + }), + CvC: vi.fn().mockResolvedValue({ + score: 2, + log: { player: 1, computer: 0, result: 'win' }, + }), +}; + +describe('RacingGame', () => { + let game; beforeEach(() => { - display = { - print: vi.fn(), - printError: vi.fn(), - read: vi.fn(), - } + game = new RacingGame({ miniGames: { MockGame: mockMiniGame } }); + }); + + test('최대 라운드를 설정할 수 있다.', () => { + game.setMaxRound(3); + expect(game.maxRound).toBe(3); + }); + + test('자동차를 생성할 수 있다.', () => { + game.setCars(['자동차1'], ['자동차2']); + expect(game.cars).toHaveLength(2); + expect(game.cars[0].name).toBe('자동차1'); + expect(game.cars[1].name).toBe('자동차2'); + }); + + test('유효하지 않은 자동차에 대해 오류를 발생시킨다.', () => { + expect(() => game.setCars(['자동차1', '자동차1'], [])).toThrow( + racingValidations.uniqueCarName.errorMessage, + ); + expect(() => game.setCars([], [])).toThrow( + racingValidations.leastCarCount.errorMessage, + ); + }); + + test('유효하지 않은 최대 라운드 값에 대해 오류를 발생시킨다.', () => { + expect(() => game.setMaxRound('문자열')).toThrow( + racingValidations.maxRoundNumber.errorMessage, + ); + expect(() => game.setMaxRound(6)).toThrow( + racingValidations.maxRoundRange.errorMessage, + ); + }); + + test('유효하지 않은 미니게임 함수에 대해 오류를 발생시킨다.', () => { + const invalidMiniGame = { + CvC: vi + .fn() + .mockResolvedValue({ score: 1, log: { player: 1, computer: 0 } }), + }; + expect( + () => new RacingGame({ miniGames: { MockGame: invalidMiniGame } }), + ).toThrow(racingValidations.miniGameInterface.errorMessage); + }); + + test('유효하지 않은 미니게임 결과에 대해 오류를 발생시킨다.', async () => { + const invalidMiniGame = { + PvC: vi.fn().mockResolvedValue({ score: 1, log: { hello: 'hello' } }), + CvC: vi.fn().mockReturnValue({ score: 1, win: true }), + }; + const racingGame = new RacingGame({ + miniGames: { MockGame: invalidMiniGame }, + }); + racingGame.setCars([], ['자동차1', '자동차2']); - racingGame = new RacingGame({ - display: display, - maxRound: 3, - config: testRacingConfig, - }) - }) - - // 출력된 문자열을 확인하기 위해 display 호출의 매개변수를 가져오는 함수 - const getDisplayText = type => { - return display[type === 'error' ? 'printError' : 'print'].mock.calls.map( - call => call[0] - )[0] - } - - test('게임에 참여하는 자동차 이름은 각각 달라야 한다.', async () => { - // display.read 에서 겹치는 자동차 입력값을 읽어온 것으로 모킹 - display.read.mockResolvedValue('car1,car2,car1') - - // matcher 가 1번은 실행되야 함 (toBe 메소드가 실행되어야 함 = 에러 발생해야 함) - expect.assertions(1) try { - await racingGame.setup() + await racingGame.doRound(); } catch (error) { - // 에러 메시지 확인 - expect(error.message).toBe(racingValidations.uniqueCarName.errorMessage) + expect(error.message).toBe(racingValidations.miniGameResult.errorMessage); } - }) + }); - test('게임에는 최소 2대의 자동차가 참여해야 한다.', async () => { - // display.read 에서 1대 자동차를 읽어온 것으로 모킹 - display.read.mockResolvedValue('car1') + test('우승자를 올바르게 계산할 수 있다.', async () => { + game.setCars(['자동차1'], ['자동차2']); + await game.doRound(); - expect.assertions(1) - try { - await racingGame.setup() - } catch (error) { - expect(error.message).toBe(racingValidations.leastCarCount.errorMessage) - } - }) - - const testCarMovement = move => { - racingGame.carNames = ['car1'] - racingGame.cars = { car1: new Car('car1') } - - // getRandomNumber 에서 설정한 가짜 값을 반환하도록 spyOn 호출 - const mockGetRandomNumber = vi.spyOn(randomNumberModule, 'getRandomNumber') - - // 기준치 초과, 또는 기준치 이하를 반환하도록 하여 테스트 - mockGetRandomNumber.mockReturnValue( - move ? testRacingConfig.threshold + 1 : testRacingConfig.threshold - ) - // move 함수 모킹 - racingGame.cars['car1'].move = vi.fn() - racingGame.eachRound() - - if (move) expect(racingGame.cars['car1'].move).toBeCalled() // '전진한다' 테스트 - else expect(racingGame.cars['car1'].move).not.toBeCalled() // '움직이지 않는다' 테스트 - mockGetRandomNumber.mockRestore() - } - - test('자동차는 라운드에서 랜덤 숫자가 설정값보다 크면 전진한다.', () => { - // getRandomNumber 가 기준치 초과해서 반환했을 때의 테스트 작동 - testCarMovement(true) - }) - - test('자동차는 라운드에서 랜덤 숫자가 설정값보다 작거나 같으면 움직이지 않는다', () => { - // getRandomNumber 가 기준치 이하을 반환했을 때의 테스트 작동 - testCarMovement(false) - }) - - test('가장 많이 움직인 자동차들이 게임에서 승리한다', () => { - racingGame.carNames = ['car1', 'car2', 'car3'] - racingGame.cars = { - car1: new Car('car1'), - car2: new Car('car2'), - car3: new Car('car3'), - } + expect(game.winners).toEqual(['자동차2']); + }); + + test('점수를 올바르게 저장하고 계산할 수 있다.', async () => { + game.setCars(['자동차1'], ['자동차2']); + + await game.doRound(); + await game.doRound(); - // 1,3 번 자동차만 이동 - racingGame.cars['car1'].move() - racingGame.cars['car3'].move() + expect(game.lastResult.positions).toEqual({ + 자동차1: 2, + 자동차2: 4, + }); + expect(game.maxPosition).toBe(4); + }); - // winner 확인 - expect(racingGame.winners).toEqual(['car1', 'car3']) + test('라운드 종료 후 이벤트가 발생한다..', async () => { + const roundEndSpy = vi.spyOn(game, 'emitEvent'); - racingGame.finish() - const winnerMessage = getDisplayText() + game.setCars(['자동차1'], ['자동차2']); + await game.doRound(); - // 우승자 출력 확인 - expect(winnerMessage).toContain('car1') - expect(winnerMessage).toContain('car3') - }) -}) + expect(roundEndSpy).toHaveBeenCalledWith('roundEnd'); + }); +}); diff --git a/src/__tests__/racingGameController.test.js b/src/__tests__/racingGameController.test.js new file mode 100644 index 0000000..971b72b --- /dev/null +++ b/src/__tests__/racingGameController.test.js @@ -0,0 +1,39 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import RacingGame from '../domains/racingGame/RacingGame.js'; +import RacingGameController from '../domains/racingGame/RacingGameController.js'; + +const mockMiniGame = { + PvC: null, + CvC: null, +}; + +const mockViewer = { + readPlayerCarNames: vi.fn().mockResolvedValue('자동차1,자동차2'), + readBotCarNames: vi.fn().mockResolvedValue('봇1,봇2,봇3'), + readRoundCount: vi.fn().mockResolvedValue('3'), +}; + +describe('RacingGameController', () => { + let racingGame, viewer, controller; + + beforeEach(() => { + racingGame = new RacingGame({ miniGames: { MockGame: mockMiniGame } }); + viewer = mockViewer; + controller = new RacingGameController({ racingGame, viewer }); + }); + + test('플레이어와 봇의 이름을 설정한다.', async () => { + await controller.setupNames(); + + expect(racingGame.players).toEqual(['자동차1', '자동차2']); + expect(racingGame.cars.length).toBe(5); + }); + + test('최대 라운드를 설정한다.', async () => { + vi.spyOn(racingGame, 'setMaxRound'); + await controller.setupMaxRound(); + + expect(racingGame.maxRound).toBe(3); + }); +}); diff --git a/src/domains/Car.js b/src/domains/Car.js index 2a4e736..26ab830 100644 --- a/src/domains/Car.js +++ b/src/domains/Car.js @@ -1,28 +1,32 @@ -import { carValidations } from '../validations/car.js' +import { carValidations } from '../validations/car.js'; export default class Car { - #position + #position; constructor(name) { - this.name = name.replaceAll(' ', '') + this.name = name.replaceAll(' ', ''); - this.validate() - this.#position = 0 + this.validate(); + this.#position = 0; } get position() { - return this.#position + return this.#position; } - move() { - this.#position += 1 + move(diff) { + if (this.#position + diff < 0) { + this.#position = 0; + } else { + this.#position += diff; + } } validate() { Object.values(carValidations).forEach(({ check, errorMessage }) => { if (!check(this.name)) { - throw new Error(errorMessage) + throw new Error(errorMessage); } - }) + }); } } diff --git a/src/domains/Game.js b/src/domains/Game.js index f97b18d..404cc42 100644 --- a/src/domains/Game.js +++ b/src/domains/Game.js @@ -1,46 +1,54 @@ +import { getRandomNumber } from '../utils/getRandomNumber.js'; +import { EventEmitter } from 'events'; + export default class Game { - constructor({ display, maxRound, config }) { - this.display = display - this.currentRound = 0 - this.maxRound = maxRound - this.config = config - this.rounds = [] - this.bindRounds() - } - - async setup() {} - - eachRound() {} - - finish() {} - - bindRounds() { - if (Object.getPrototypeOf(this).hasOwnProperty('eachRound')) { - this.rounds = Array.from({ length: this.maxRound }, _ => - this.eachRound.bind(this) - ) - } else { - const roundMethodNames = Object.getOwnPropertyNames( - Object.getPrototypeOf(this) - ).filter(prop => prop.startsWith('round') && prop !== 'rounds') - this.rounds = roundMethodNames.map(methodName => - this[methodName].bind(this) - ) - } + #results; + #miniGames; + + constructor({ miniGames }) { + this.maxRound = 1; + this.currentRound = 1; + + this.#miniGames = miniGames; + this.currentMiniGame = ''; + + this.#results = []; + this.eventEmitter = new EventEmitter(); } - async play() { - try { - await this.setup() - } catch (error) { - this.display.printError(error) - return this.play() - } + get results() { + return this.#results; + } - while (this.currentRound < this.maxRound) { - this.rounds[this.currentRound++]() - } + get miniGames() { + return this.#miniGames; + } - this.finish() + get lastResult() { + return this.#results.at(-1); + } + + selectRandomMiniGame() { + const miniGameNames = Object.keys(this.miniGames); + this.currentMiniGame = + miniGameNames[getRandomNumber(0, miniGameNames.length - 1)]; + } + + emitEvent(eventName, ...args) { + this.eventEmitter.emit(eventName, ...args); + } + + addResult(resultAfterRound) { + this.#results.push(resultAfterRound); + } + + doRound() {} + + async play(startRound = this.currentRound, endRound = this.maxRound) { + this.currentRound = startRound; + while (this.currentRound <= endRound) { + await this.doRound(); + this.currentRound += 1; + } } } diff --git a/src/domains/RacingGame.js b/src/domains/RacingGame.js deleted file mode 100644 index da6cefc..0000000 --- a/src/domains/RacingGame.js +++ /dev/null @@ -1,75 +0,0 @@ -import Game from './Game.js' -import Car from './Car.js' -import { getRandomNumber } from '../utils/getRandomNumber.js' -import { racingValidations } from '../validations/racing.js' - -export default class RacingGame extends Game { - cars = {} - carNames = [] - - get winners() { - const maxPosition = Math.max( - ...this.carNames.map(name => this.cars[name].position) - ) - return this.carNames.filter( - name => this.cars[name].position === maxPosition - ) - } - - async setup() { - this.initializeCars() - const carNames = await this.readCarNames() - carNames.forEach(name => this.registerCar(name)) - this.validate() - } - - eachRound() { - this.display.print('') - this.carNames.forEach(name => this.moveCarByRandomNumber(name)) - this.carNames.forEach(name => this.showCarPosition(name)) - } - - finish() { - this.showWinners() - } - - initializeCars() { - this.cars = {} - this.carNames = [] - } - - async readCarNames() { - return ( - await this.display.read( - '경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).' - ) - ).split(',') - } - - registerCar(name) { - const registeredCar = new Car(name) - this.cars[registeredCar.name] = registeredCar - this.carNames.push(registeredCar.name) - } - - validate() { - Object.values(racingValidations).forEach(({ check, errorMessage }) => { - if (!check(this.carNames)) { - throw new Error(errorMessage) - } - }) - } - - moveCarByRandomNumber(name) { - const { min, max, threshold } = this.config - if (getRandomNumber(min, max) > threshold) this.cars[name].move() - } - - showCarPosition(name) { - this.display.print(`${name} : ${'_'.repeat(this.cars[name].position)}`) - } - - showWinners() { - this.display.print(`\n🎉 우승자 : ${this.winners.join(', ')} 🎉\n`) - } -} diff --git a/src/domains/miniGames/DiceDiffGame.js b/src/domains/miniGames/DiceDiffGame.js new file mode 100644 index 0000000..78ea453 --- /dev/null +++ b/src/domains/miniGames/DiceDiffGame.js @@ -0,0 +1,32 @@ +import { getRandomNumber } from '../../utils/getRandomNumber.js'; +import readInput from '../../utils/readInput.js'; + +export default class DiceDiffGame { + static get diceNumber() { + return getRandomNumber(1, 6); + } + + static getPlayerResult(playerName, playerAnswer, opponentAnswer) { + const diff = playerAnswer - opponentAnswer; + return { + score: diff, + log: { + player: playerAnswer, + computer: opponentAnswer, + result: diff, + }, + }; + } + + static CvC(playerName) { + return this.getPlayerResult(playerName, this.diceNumber, this.diceNumber); + } + + static async PvC(playerName) { + await readInput( + '주사위 던지기 게임 : Enter 입력 시 주사위를 던지고, 그 차이만큼 이동합니다.', + ); + const answer = this.diceNumber; + return this.getPlayerResult(`*${playerName}`, answer, this.diceNumber); + } +} diff --git a/src/domains/miniGames/GuessRandomNumber.js b/src/domains/miniGames/GuessRandomNumber.js new file mode 100644 index 0000000..1e541a8 --- /dev/null +++ b/src/domains/miniGames/GuessRandomNumber.js @@ -0,0 +1,41 @@ +import { getRandomNumber } from '../../utils/getRandomNumber.js'; +import readInput from '../../utils/readInput.js'; +import { guessRandomNumberValidations } from '../../validations/miniGames.js'; + +export default class GuessRandomNumber { + static get randomNumber() { + return getRandomNumber(1, 10); + } + + static getPlayerResult(playerName, playerAnswer, opponentAnswer) { + const win = playerAnswer === opponentAnswer; + return { + win, + log: { + player: playerAnswer, + computer: opponentAnswer, + result: win ? 'win' : 'lose', + }, + }; + } + + static CvC(playerName) { + return this.getPlayerResult( + playerName, + this.randomNumber, + this.randomNumber, + ); + } + + static async PvC(playerName) { + const answer = await readInput( + '숫자 맞추기 대결 : 1 부터 10 까지의 숫자 중 하나를 입력\n', + guessRandomNumberValidations, + ); + return this.getPlayerResult( + `*${playerName}`, + Number(answer), + this.randomNumber, + ); + } +} diff --git a/src/domains/miniGames/GuessTimeOut.js b/src/domains/miniGames/GuessTimeOut.js new file mode 100644 index 0000000..c5a01d2 --- /dev/null +++ b/src/domains/miniGames/GuessTimeOut.js @@ -0,0 +1,40 @@ +import { getRandomNumber } from '../../utils/getRandomNumber.js'; +import readInput from '../../utils/readInput.js'; + +export default class GuessTimeOut { + static get randomSeconds() { + return getRandomNumber(5, 10); // 5초에서 10초 사이의 랜덤 시간 생성 + } + + static async getPlayerTime(opponentTime) { + const startTime = Date.now(); + await readInput(`타이머 맞추기: ${opponentTime} 뒤에 Enter 누르기`); + const endTime = Date.now(); + return (endTime - startTime) / 1000; // 밀리초를 초로 변환 + } + + static getPlayerResult(playerName, playerTime, opponentTime) { + const difference = Math.abs(playerTime - opponentTime); + const win = difference < 0.5; + return { + win, + log: { + player: playerTime.toFixed(2), + computer: opponentTime.toFixed(2), + result: win ? 'win' : 'lose', + }, + }; + } + + static CvC(playerName) { + const opponentTime = this.randomSeconds; + const playerTime = opponentTime + (Math.random() * 2 - 1); + return this.getPlayerResult(playerName, playerTime, opponentTime); + } + + static async PvC(playerName) { + const opponentTime = this.randomSeconds; + const playerTime = await this.getPlayerTime(opponentTime); + return this.getPlayerResult(`*${playerName}`, playerTime, opponentTime); + } +} diff --git a/src/domains/miniGames/RockPaperScissors.js b/src/domains/miniGames/RockPaperScissors.js new file mode 100644 index 0000000..f16f382 --- /dev/null +++ b/src/domains/miniGames/RockPaperScissors.js @@ -0,0 +1,72 @@ +import { getRandomNumber } from '../../utils/getRandomNumber.js'; +import readInput from '../../utils/readInput.js'; +import { rockPaperScissorsValidations } from '../../validations/miniGames.js'; + +const rockScissorPaperMap = { + rock: { + win: 'scissor', + icon: '👊', + }, + scissor: { + win: 'paper', + icon: '✌️', + }, + paper: { + win: 'rock', + icon: '✋', + }, +}; +export default class RockPaperScissors { + static get answerList() { + return Object.keys(rockScissorPaperMap); + } + + static get randomAnswer() { + return this.answerList[getRandomNumber(0, 2)].padEnd(2, ' '); + } + + static getIcon(answer) { + return rockScissorPaperMap[answer].icon.padEnd(3, ' '); + } + + static getPlayerResult(playerName, playerAnswer, opponentAnswer) { + let score = 0; + let result = 'draw'; + if (rockScissorPaperMap[playerAnswer].win === opponentAnswer) { + score = 1; + result = 'win'; + } else if (rockScissorPaperMap[opponentAnswer].win === playerAnswer) { + score = -1; + result = 'lose'; + } + return { + score, + log: { + player: this.getIcon(playerAnswer), + computer: this.getIcon(opponentAnswer), + result, + }, + }; + } + + static CvC(playerName) { + return this.getPlayerResult( + playerName, + this.randomAnswer, + this.randomAnswer, + ); + } + + static async PvC(playerName) { + const answerIndex = await readInput( + '가위바위보 대결 : 1.바위 2.가위 3.보\n', + rockPaperScissorsValidations, + ); + const playerAnswer = this.answerList[Number(answerIndex) - 1]; + return this.getPlayerResult( + `*${playerName}`, + playerAnswer, + this.randomAnswer, + ); + } +} diff --git a/src/domains/racingGame/RacingGame.js b/src/domains/racingGame/RacingGame.js new file mode 100644 index 0000000..a34ea70 --- /dev/null +++ b/src/domains/racingGame/RacingGame.js @@ -0,0 +1,92 @@ +import Car from '../Car.js'; +import Game from '../Game.js'; +import { racingValidations } from '../../validations/racing.js'; +import createValidator from '../../validations/createValidator.js'; + +export default class RacingGame extends Game { + constructor({ miniGames }) { + super({ miniGames }); + this.validate = createValidator(racingValidations); + this.validate(this.miniGames, ['miniGameInterface', 'miniGameSize']); + } + + get maxPosition() { + return Math.max(...Object.values(this.lastResult.positions)); + } + + get winners() { + const { positions } = this.lastResult; + return Object.keys(positions).filter( + name => positions[name] === this.maxPosition, + ); + } + + setMaxRound(maxRound) { + this.maxRound = maxRound; + this.validate(this.maxRound, ['maxRoundNumber', 'maxRoundRange']); + } + + setCars(playerNames, botNames) { + this.cars = []; + this.players = []; + [...playerNames, ...botNames].forEach(name => { + const newCar = new Car(name); + this.cars.push(newCar); + if (playerNames.includes(name)) { + this.players.push(newCar.name); + } + }); + this.validate(this.cars, ['leastCarCount', 'uniqueCarName']); + } + + async doRound() { + this.selectRandomMiniGame(); + this.emitEvent('roundStart'); + + for (const car of this.cars) { + await this.race(car); + } + + this.addResult(this.createResult()); + this.emitEvent('roundEnd'); + } + + createResult() { + const result = { + ruleName: this.currentMiniGame, + positions: this.cars.reduce( + (acc, car) => ({ + ...acc, + [car.name]: car.position, + }), + {}, + ), + gameLogs: this.gameLogs, + }; + this.gameLogs = {}; + return result; + } + + async race(car) { + const miniGameResult = await this.doMiniGame(car); + this.validate(miniGameResult, ['miniGameResult']); + + if (miniGameResult.hasOwnProperty('score')) { + car.move(miniGameResult.score); + } + if (miniGameResult.hasOwnProperty('win')) { + car.move(miniGameResult.win ? 1 : 0); + } + this.gameLogs = { ...this.gameLogs, [car.name]: miniGameResult.log }; + } + + async doMiniGame(car) { + const miniGame = this.miniGames[this.currentMiniGame]; + + if (this.players.includes(car.name)) { + this.emitEvent('miniGameStart', car.name); + return await miniGame.PvC(car.name); + } + return miniGame.CvC(car.name); + } +} diff --git a/src/domains/racingGame/RacingGameController.js b/src/domains/racingGame/RacingGameController.js new file mode 100644 index 0000000..6cffdf5 --- /dev/null +++ b/src/domains/racingGame/RacingGameController.js @@ -0,0 +1,57 @@ +export default class RacingGameController { + constructor({ racingGame, viewer }) { + this.racingGame = racingGame; + this.viewer = viewer; + + this.racingGame.eventEmitter.on('roundStart', () => this.onRoundStart()); + this.racingGame.eventEmitter.on('roundEnd', () => this.onRoundEnd()); + this.racingGame.eventEmitter.on('miniGameStart', name => + this.onMiniGameStart(name), + ); + } + + onRoundStart() { + this.viewer.displayStartRound(this.racingGame); + } + + onMiniGameStart(name) { + this.viewer.displayMiniGameStart(name); + } + + onRoundEnd() { + this.viewer.displayGameLogs(this.racingGame); + this.viewer.displayRoundResult(this.racingGame); + } + + async startGame() { + this.viewer.displayGameStart(); + await this.setupNames(); + await this.setupMaxRound(); + await this.racingGame.play(); + this.viewer.displayWinners(this.racingGame); + } + + async setupNames() { + try { + const playerNames = await this.viewer.readPlayerCarNames(); + const botNames = await this.viewer.readBotCarNames(); + this.racingGame.setCars( + playerNames.split(',').filter(Boolean), + botNames.split(',').filter(Boolean), + ); + } catch (error) { + this.viewer.displayError(error); + await this.setupNames(); + } + } + + async setupMaxRound() { + try { + const maxRound = await this.viewer.readRoundCount(); + this.racingGame.setMaxRound(Number(maxRound)); + } catch (error) { + this.viewer.displayError(error); + await this.setupMaxRound(); + } + } +} diff --git a/src/domains/racingGame/RacingGameViewer.js b/src/domains/racingGame/RacingGameViewer.js new file mode 100644 index 0000000..eb6ee9f --- /dev/null +++ b/src/domains/racingGame/RacingGameViewer.js @@ -0,0 +1,105 @@ +import ConsolePrinter from '../../service/Printer.js'; +import readInput from '../../utils/readInput.js'; + +export default class RacingGameViewer { + constructor() { + this.printer = new ConsolePrinter({ + roundStart: + '--------------------------⭐ Round%{1}⭐️️--------------------------', + carPosition: '%{1} : %{2} (%{3}%{4} ➡ %{5})', + gameLog: '%{1} : %{2} VS computer : %{3} ➡➡ %{4}', + miniGameStart: '>> player %{1} Turn!', + winner: '최종 우승자는 👑 %{1} 입니다. 축하합니다!', + error: '⚠️ %{1}', + divider: + '---------------------------------------------------------------', + }); + } + + displayGameStart() { + this.printer.print('🚕 레이싱 게임을 시작합니다 🚗'); + this.printer.print( + '각 라운드마다 랜덤 미니 게임을 진행하여 이동할 수 있습니다.', + ); + this.printer.lineBreak(); + } + + displayStartRound({ currentRound }) { + this.printer.printWithTemplate('roundStart', [currentRound]); + } + + displayMiniGameStart(playerName) { + this.printer.lineBreak(); + this.printer.printWithTemplate('miniGameStart', [playerName]); + } + + displayGameLogs({ lastResult }) { + const { gameLogs } = lastResult; + this.printer.lineBreak(); + Object.entries(gameLogs).forEach(([name, log]) => { + this.printer.printWithTemplate('gameLog', [ + name.padEnd(5, ' '), + ...Object.values(log), + ]); + }); + this.printer.lineBreak(); + } + + displayRoundResult({ results, maxPosition, currentRound }) { + const { positions } = results[currentRound - 1]; + const prevPositions = + currentRound > 1 ? results[currentRound - 2].positions : {}; + Object.entries(positions).forEach(([name, position]) => { + const positionString = this.formatPositionString(position, maxPosition); + const positionDiffArgs = this.getPositionDiffArgs( + position, + prevPositions[name] || 0, + ); + this.printer.printWithTemplate('carPosition', [ + name.padEnd(5, ' '), + positionString, + ...positionDiffArgs, + ]); + }); + this.printer.lineBreak(); + } + + displayWinners({ winners }) { + this.printer.printWithTemplate('winner', [winners.join(', ')]); + this.printer.lineBreak(); + } + + displayError(errorMessage) { + this.printer.printWithTemplate('divider'); + this.printer.printWithTemplate('error', [errorMessage]); + this.printer.printWithTemplate('divider'); + this.printer.lineBreak(); + } + + async readPlayerCarNames() { + return await readInput( + '직접 레이싱에 참여할 자동차들의 이름을 입력해주세요. (쉼표로 구분)\n', + ); + } + + async readBotCarNames() { + return await readInput( + '봇으로 참여할 자동차들의 이름을 입력해주세요. (쉼표로 구분)\n', + ); + } + + async readRoundCount() { + return await readInput('몇 라운드 플레이할 지 알려주세요.\n'); + } + + formatPositionString(position, maxPosition) { + return '⛳️' + .padStart((position + 1) * 2, '__') + .padEnd((maxPosition + 1) * 2, '__'); + } + + getPositionDiffArgs(position, prevPosition) { + const positionDiff = position - prevPosition; + return [positionDiff < 0 ? '' : '+', positionDiff, position]; + } +} diff --git a/src/main.js b/src/main.js index 39a300e..522e419 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,23 @@ -import RacingGame from './domains/RacingGame.js' -import Printer from './service/Printer.js' +import RacingGame from './domains/racingGame/RacingGame.js'; +import RacingGameController from './domains/racingGame/RacingGameController.js'; +import RacingGameViewer from './domains/racingGame/RacingGameViewer.js'; +import RockPaperScissors from './domains/miniGames/RockPaperScissors.js'; +import GuessRandomNumber from './domains/miniGames/GuessRandomNumber.js'; +import GuessTimeOut from './domains/miniGames/GuessTimeOut.js'; +import DiceDiffGame from './domains/miniGames/DiceDiffGame.js'; async function main() { - const printer = new Printer() - - const racingGame = new RacingGame({ - display: printer, - maxRound: 5, - config: { - min: 0, - max: 9, - threshold: 4, - }, - }) - await racingGame.play() + await new RacingGameController({ + racingGame: new RacingGame({ + miniGames: { + RockPaperScissors, + GuessRandomNumber, + GuessTimeOut, + DiceDiffGame, + }, + }), + viewer: new RacingGameViewer(), + }).startGame(); } -main() +main(); diff --git a/src/service/Printer.js b/src/service/Printer.js index f400fc7..41d1ada 100644 --- a/src/service/Printer.js +++ b/src/service/Printer.js @@ -1,27 +1,31 @@ -import createReadline from '../utils/createReadline.js' - -export default class Printer { - constructor() { - this.rl = createReadline() +export default class ConsolePrinter { + constructor(template) { + this.template = template; } - print(message) { - console.log(message) + format(templateKey, messages) { + let result = this.template[templateKey]; + if (messages && messages.length > 0) { + messages.forEach((message, index) => { + result = result.replaceAll(`%{${index + 1}}`, message); + }); + } + return result; } - printError(error) { - console.log(`\n⚠️ ${error.message}\n`) + print(...messages) { + console.log(...messages); } - async read(message) { - if (!this.rl.isOpened) { - this.rl = createReadline() + printWithTemplate(templateKey, messages) { + if (this.template.hasOwnProperty(templateKey)) { + console.log(this.format(templateKey, messages)); + } else { + console.log(...messages); } - return new Promise(resolve => { - this.rl.question(`⬇️ ${message}\n\n`, input => { - resolve(input) - this.rl.close() - }) - }) + } + + lineBreak() { + console.log(''); } } diff --git a/src/utils/createReadline.js b/src/utils/createReadline.js index 07e50ae..3131ce1 100644 --- a/src/utils/createReadline.js +++ b/src/utils/createReadline.js @@ -1,15 +1,15 @@ -import readline from 'readline' +import readline from 'readline'; const createReadline = () => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, - }) - rl.isOpened = true + }); + rl.isOpened = true; rl.on('close', () => { - rl.isOpened = false - }) - return rl -} + rl.isOpened = false; + }); + return rl; +}; -export default createReadline +export default createReadline; diff --git a/src/utils/getRandomNumber.js b/src/utils/getRandomNumber.js index 265bae4..c624e58 100644 --- a/src/utils/getRandomNumber.js +++ b/src/utils/getRandomNumber.js @@ -1,3 +1,3 @@ export const getRandomNumber = (min, max) => { - return Math.floor(Math.random() * (max - min + 1)) + min -} + return Math.floor(Math.random() * (max - min + 1)) + min; +}; diff --git a/src/utils/readInput.js b/src/utils/readInput.js new file mode 100644 index 0000000..57a3a69 --- /dev/null +++ b/src/utils/readInput.js @@ -0,0 +1,32 @@ +import createReadline from './createReadline.js'; + +const askQuestion = (rl, message) => { + return new Promise(resolve => { + rl.question(message, input => { + resolve(input); + }); + }); +}; + +const findInputError = (input, validations) => { + const { errorMessage } = validations.find(({ check }) => !check(input)) ?? {}; + return errorMessage; +}; + +const readInput = (message, validations = [], option = 'repeat') => { + const rl = createReadline(); + const processInput = async () => { + const input = await askQuestion(rl, message); + const errorMessage = findInputError(input, validations); + + if (option === 'repeat' && errorMessage) { + console.error(errorMessage); + return processInput(); + } + rl.close(); + return input; + }; + return processInput(); +}; + +export default readInput; diff --git a/src/validations/car.js b/src/validations/car.js index 73a3ee2..8d4eaa7 100644 --- a/src/validations/car.js +++ b/src/validations/car.js @@ -1,6 +1,6 @@ export const carValidations = { - carNameLength: { - check: name => name.length >= 1 && name.length <= 5, - errorMessage: '자동차 이름은 1자 이상 5자 이하여야 합니다.' - } -} + carNameLength: { + check: name => name.length >= 1 && name.length <= 5, + errorMessage: '자동차 이름은 1자 이상 5자 이하여야 합니다.', + }, +}; diff --git a/src/validations/createValidator.js b/src/validations/createValidator.js new file mode 100644 index 0000000..08dfce8 --- /dev/null +++ b/src/validations/createValidator.js @@ -0,0 +1,14 @@ +const createValidator = validations => { + return (target, validationKeys) => { + validationKeys.forEach(key => { + if (!validations.hasOwnProperty(key)) { + throw new Error('올바른 검사 키가 아닙니다.'); + } + if (!validations[key].check(target)) { + throw new Error(validations[key].errorMessage); + } + }); + }; +}; + +export default createValidator; diff --git a/src/validations/miniGames.js b/src/validations/miniGames.js new file mode 100644 index 0000000..6ea73c2 --- /dev/null +++ b/src/validations/miniGames.js @@ -0,0 +1,16 @@ +export const rockPaperScissorsValidations = [ + { + check: input => [1, 2, 3].includes(Number(input)), + errorMessage: '가위바위보 입력값은 1,2,3 중 하나여야 합니다.', + }, +]; + +export const guessRandomNumberValidations = [ + { + check: input => + Number(input) >= 1 && + Number(input) <= 10 && + Number.isInteger(Number(input)), + errorMessage: '1 부터 10 까지의 자연수만 가능합니다.', + }, +]; diff --git a/src/validations/racing.js b/src/validations/racing.js index 2185e6a..c6eab86 100644 --- a/src/validations/racing.js +++ b/src/validations/racing.js @@ -1,10 +1,50 @@ export const racingValidations = { - uniqueCarName: { - check: (carNames) => new Set(carNames).size === carNames.length, - errorMessage: '경주할 자동차 이름은 각각 달라야 합니다.' + uniqueCarName: { + check: cars => { + const carNames = cars.map(({ name }) => name); + return new Set(carNames).size === carNames.length; }, - leastCarCount: { - check: (carNames) => carNames.length > 1, - errorMessage: '최소 2대의 자동차가 참가해야 합니다.' - } -} + errorMessage: '경주할 자동차 이름은 각각 달라야 합니다.', + }, + leastCarCount: { + check: cars => cars.length > 1, + errorMessage: '최소 2대의 자동차가 참가해야 합니다.', + }, + maxRoundNumber: { + check: maxRound => Number.isInteger(maxRound), + errorMessage: '최대 라운드 값은 숫자여야 합니다.', + }, + maxRoundRange: { + check: maxRound => 1 <= maxRound && maxRound <= 5, + errorMessage: '최소 1라운드부터 최대 5라운드 동안 플레이할 수 있습니다.', + }, + miniGameInterface: { + check: miniGames => + Object.values(miniGames).every( + game => game.hasOwnProperty('CvC') && game.hasOwnProperty('PvC'), + ), + errorMessage: + '올바른 미니게임이 아닙니다. 플레이 기능이 존재하지 않습니다.', + }, + miniGameSize: { + check: miniGames => Object.values(miniGames).length > 0, + errorMessage: '최대 1개 이상의 미니게임이 있어야 합니다.', + }, + miniGameResult: { + check: miniGameResult => { + const isResultCorrect = + (miniGameResult.hasOwnProperty('score') || + miniGameResult.hasOwnProperty('win')) && + miniGameResult.hasOwnProperty('log'); + + const { log } = miniGameResult; + return ( + isResultCorrect && + log.hasOwnProperty('player') && + log.hasOwnProperty('computer') && + log.hasOwnProperty('result') + ); + }, + errorMessage: '올바른 미니게임 결과가 아닙니다.', + }, +};