diff --git a/.gitmessage.txt b/.gitmessage.txt new file mode 100644 index 00000000..dc32db71 --- /dev/null +++ b/.gitmessage.txt @@ -0,0 +1,7 @@ +#(): <제목 50자 이내, 마침표X> + +# 왜 + 무엇을 + +# type 가이드 : feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert +# scope 예시 : input, rule, round, result, output, app, validation 등 +# 규칙 : 제목/본문 사이 빈 줄 1개, 제목 끝에 마침표 금지, 이슈/PR 번호 금지 \ No newline at end of file diff --git a/README.md b/README.md index e078fd41..8cc61bcb 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ # javascript-racingcar-precourse + +## 기능 목록 + +### 입력 + +- [ ] 이름 받기: 쉼표로 나누기 -> 양쪽 공백 제거 -> 빈 값 거절 +- [ ] 이름 규칙 지키기: 각 1~5자, 중복 그지, 최소 1대 이상 +- [ ] 횟수 받기: 숫자만, 1 이상 (0/음수/소수/문자 섞임 거절) + +### 진행 규칙 + +- [ ] 난수 범위: 0~9 정수 뽑기 +- [ ] 전진 조건: 값이 4 이상이면 앞으로 1칸 + +### 라운드 + +- [ ] 라운드 돌리기: 입력한 횟수만큼 반복, 모든 자동차에 규칙 적용 +- [ ] 상태 유지: 각 자동차 이동 누적 거리 기록 +- [ ] 라운드 출력: '이름: ----' 형식으로 출력 +- [ ] 실행 결과 헤더: 첫라운드 전 '실행 결과' 출력 + +### 결과 + +- [ ] 우승자 뽑기: 최댓값 찾기, 여러 명이면 모두 포함 +- [ ] 최종 안내: '최종 우승자 : 이름1, 이름2' 형식으로 출력 + +### 오류 / 종료 + +- [ ] 잘못된 입력: '[ERROR] ...' 형식의 오류 메세지 출력 후 종료 + +## 테스트 목록 + +### 입력 + +- [ ] 이름 파싱 (쉽표 분리, 공백 제거, 빈 값 거절) 동작 확인 +- [ ] 이름 입력 오류 확인 +- [ ] 시도 횟수 입력 오류 확인 +- [ ] 정상 케이스 동작 확인 + +### 진행 규칙 + +- [ ] 전진 규칙 경계 확인 (3 정지, 4 전진) +- [ ] 난수 사용 확인 (0~9 범위, 호출수=라운드x차량수) + +### 라운드 + +- [ ] 첫 라운드 전 '실행 결과' 헤더 1회 출력 확인 +- [ ] 라운드별 위치 누적 일치 확인 +- [ ] 출력 형식 확인 ('이름: ----' , 라운드들 사이에 빈 줄 1개 ) + +### 결과 + +- [ ] 우승자: 단독/공동 (입력 순서 유지)/전원 공동 출력 확인 +- [ ] 최종 안내: '최종 우승자 : 이름1, 이름2' 형식으로 출력 확인 + +### 오류 + +- [ ] 에러 시 지정 메세지 출력 후 즉시 종료 diff --git a/src/App.js b/src/App.js index 091aa0a5..587eb6c8 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import RacingGame from "./usecases/RacingGame.js"; + class App { - async run() {} + async run() { + const game = new RacingGame(); + await game.run(); + } } export default App; diff --git a/src/constants/messages.js b/src/constants/messages.js new file mode 100644 index 00000000..71327a4a --- /dev/null +++ b/src/constants/messages.js @@ -0,0 +1,19 @@ +const MESSAGES = { + ERROR: { + NAMES_REQUIRED: "[ERROR] 자동차 이름을 입력하세요.", + EMPTY_NAME: "[ERROR] 자동차 이름은 공백일 수 없습니다.", + MIN_ONE_CAR: "[ERROR] 최소 한 대 이상의 자동차 이름을 입력하세요.", + NAME_LENGTH: (n) => "[ERROR] 자동차 이름은 최대 5글자까지 가능합니다.", + DUP_NAME: (n) => "[ERROR] 중복된 자동차 이름이 있습니다.", + TRY_REQUIRED: "[ERROR] 시도 횟수를 입력하세요.", + TRY_POS_INT: "[ERROR] 시도 횟수는 1 이상의 정수여야 합니다.", + }, + TEXT: { + ASK_NAMES: "경주할 자동차 이름을 입력하세요. (이름은 쉼표(,)로 구분)", + ASK_TRIES: "시도할 횟수는 몇 회인가요?", + HEADER: "실행 결과", + WINNERS_PREFIX: "최종 우승자 : ", + }, +}; + +export default MESSAGES; diff --git a/src/domain/Game.js b/src/domain/Game.js new file mode 100644 index 00000000..e0252c7e --- /dev/null +++ b/src/domain/Game.js @@ -0,0 +1,69 @@ +class Game { + static createCars(carNames) { + const cars = []; + for (const name of carNames) { + cars.push({ name, pos: 0 }); + } + return cars; + } + + static advance(car) { + car.pos += 1; + } + + static playRound(cars, randomDigitPicker, advanceDecider) { + for (const car of cars) { + const digit = randomDigitPicker(); + const canAdvance = advanceDecider(digit); + if (canAdvance) { + Game.advance(car); + } + } + } + + static runRounds( + cars, + totalRounds, + randomDigitPicker, + advanceDecider, + onAfterRound + ) { + let roundIndex = 0; + while (roundIndex < totalRounds) { + Game.playRound(cars, randomDigitPicker, advanceDecider); + + const roundSnapshot = []; + for (const car of cars) { + roundSnapshot.push({ name: car.name, pos: car.pos }); + } + + if (onAfterRound) { + onAfterRound(roundIndex, totalRounds, roundSnapshot); + } + roundIndex += 1; + } + } + + static getMaxPosition(cars) { + let maxPosition = 0; + for (const car of cars) { + if (car.pos > maxPosition) { + maxPosition = car.pos; + } + } + return maxPosition; + } + + static determineWinners(cars) { + const maxPosition = Game.getMaxPosition(cars); + const winners = []; + for (const car of cars) { + if (car.pos === maxPosition) { + winners.push(car.name); + } + } + return winners; + } +} + +export default Game; diff --git a/src/domain/Rules.js b/src/domain/Rules.js new file mode 100644 index 00000000..e33acc74 --- /dev/null +++ b/src/domain/Rules.js @@ -0,0 +1,13 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; + +class Rules { + static pickDigit() { + return MissionUtils.Random.pickNumberInRange(0, 9); + } + + static shouldAdvance(digit) { + return digit >= 4; + } +} + +export default Rules; diff --git a/src/usecases/RacePrinter.js b/src/usecases/RacePrinter.js new file mode 100644 index 00000000..addd09ef --- /dev/null +++ b/src/usecases/RacePrinter.js @@ -0,0 +1,34 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import MESSAGES from "../constants/messages.js"; +import Format from "../util/Format.js"; +import Game from "../domain/Game.js"; + +class RacePrinter { + static printHeader() { + MissionUtils.Console.print(MESSAGES.TEXT.HEADER); + } + + static printRoundSnapshot(roundIndex, totalRounds, roundSnapshot) { + for (const car of roundSnapshot) { + MissionUtils.Console.print(Format.progressLine(car.name, car.pos)); + } + if (roundIndex < totalRounds - 1) { + MissionUtils.Console.print(""); + } + } + + static printWinners(raceCars) { + const winners = Game.determineWinners(raceCars); + MissionUtils.Console.print(Format.winnersLine(winners)); + } + + static printError(error) { + let message = "[ERROR] 알 수 없는 오류가 발생했습니다."; + if (error && error.message) { + message = error.message; + } + MissionUtils.Console.print(message); + } +} + +export default RacePrinter; diff --git a/src/usecases/RacePrompter.js b/src/usecases/RacePrompter.js new file mode 100644 index 00000000..4ce8a663 --- /dev/null +++ b/src/usecases/RacePrompter.js @@ -0,0 +1,23 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import MESSAGES from "../constants/messages.js"; +import Validation from "../util/Validation.js"; + +class RacePrompter { + static async promptInputs() { + MissionUtils.Console.print(MESSAGES.TEXT.ASK_NAMES); + const namesLine = await MissionUtils.Console.readLineAsync(); + + MissionUtils.Console.print(MESSAGES.TEXT.ASK_TRIES); + const attemptsLine = await MissionUtils.Console.readLineAsync(); + + const carNames = Validation.parseNames(namesLine); + Validation.validateNames(carNames); + + const attempts = Validation.parseTryCount(attemptsLine); + Validation.validateTryCount(attempts); + + return { carNames, attempts }; + } +} + +export default RacePrompter; diff --git a/src/usecases/RacingGame.js b/src/usecases/RacingGame.js new file mode 100644 index 00000000..d82f19e1 --- /dev/null +++ b/src/usecases/RacingGame.js @@ -0,0 +1,35 @@ +import Game from "../domain/Game.js"; +import Rules from "../domain/Rules.js"; +import RacePrompter from "./RacePrompter.js"; +import RacePrinter from "./RacePrinter.js"; + +class RacingGame { + async run() { + try { + const { carNames, attempts } = await RacePrompter.promptInputs(); + const raceCars = Game.createCars(carNames); + + RacePrinter.printHeader(); + + Game.runRounds( + raceCars, + attempts, + () => Rules.pickDigit(), + (digit) => Rules.shouldAdvance(digit), + (roundIndex, totalRounds, roundSnapshot) => { + RacePrinter.printRoundSnapshot( + roundIndex, + totalRounds, + roundSnapshot + ); + } + ); + RacePrinter.printWinners(raceCars); + } catch (error) { + RacePrinter.printError(error); + throw error; + } + } +} + +export default RacingGame; diff --git a/src/util/Format.js b/src/util/Format.js new file mode 100644 index 00000000..140ac39d --- /dev/null +++ b/src/util/Format.js @@ -0,0 +1,20 @@ +import MESSAGES from "../constants/messages.js"; + +class Format { + static progressLine(name, position) { + let progressBar = ""; + let step = 0; + while (step < position) { + progressBar += "-"; + step += 1; + } + return `${name} : ${progressBar}`; + } + + static winnersLine(winnerNames) { + const joined = winnerNames.join(", "); + return `${MESSAGES.TEXT.WINNERS_PREFIX}${joined}`; + } +} + +export default Format; diff --git a/src/util/Validation.js b/src/util/Validation.js new file mode 100644 index 00000000..1244f5be --- /dev/null +++ b/src/util/Validation.js @@ -0,0 +1,60 @@ +import MESSAGES from "../constants/messages.js"; + +class Validation { + static fail(msg) { + if (msg.startsWith(`[ERROR]`)) { + throw new Error(msg); + } + throw new Error(`[ERROR] ${msg}`); + } + + static parseNames(inputText) { + const text = inputText?.trim(); + if (!text) Validation.fail(MESSAGES.ERROR.NAMES_REQUIRED); + + const names = text.split(",").map((name) => name.trim()); + if (names.some((name) => name === "")) { + Validation.fail(MESSAGES.ERROR.EMPTY_NAME); + } + return names; + } + + static validateNames(names) { + if (!Array.isArray(names) || names.length === 0) { + Validation.fail(MESSAGES.ERROR.MIN_ONE_CAR); + } + const seen = new Set(); + for (const n of names) { + if (n.length < 1 || n.length > 5) { + Validation.fail(MESSAGES.ERROR.NAME_LENGTH(n)); + } + if (seen.has(n)) { + Validation.fail(MESSAGES.ERROR.DUP_NAME(n)); + } + seen.add(n); + } + } + + static parseTryCount(inputText) { + if (inputText == null || inputText.trim() === "") { + Validation.fail(MESSAGES.ERROR.TRY_REQUIRED); + } + const s = inputText.trim(); + if (!/^-?\d+$/.test(s)) { + Validation.fail(MESSAGES.ERROR.TRY_POS_INT); + } + const n = parseInt(s, 10); + if (!Number.isSafeInteger(n) || n < 1) { + Validation.fail(MESSAGES.ERROR.TRY_POS_INT); + } + return n; + } + + static validateTryCount(n) { + if (!Number.isSafeInteger(n) || n < 1) { + Validation.fail(MESSAGES.ERROR.TRY_POS_INT); + } + } +} + +export default Validation;