Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitmessage.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#<type>(<scope>): <제목 50자 이내, 마침표X>

# 왜 + 무엇을

# type 가이드 : feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
# scope 예시 : input, rule, round, result, output, app, validation 등
# 규칙 : 제목/본문 사이 빈 줄 1개, 제목 끝에 마침표 금지, 이슈/PR 번호 금지
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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' 형식으로 출력 확인

### 오류

- [ ] 에러 시 지정 메세지 출력 후 즉시 종료
7 changes: 6 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions src/constants/messages.js
Original file line number Diff line number Diff line change
@@ -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;
69 changes: 69 additions & 0 deletions src/domain/Game.js
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions src/domain/Rules.js
Original file line number Diff line number Diff line change
@@ -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;
34 changes: 34 additions & 0 deletions src/usecases/RacePrinter.js
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 23 additions & 0 deletions src/usecases/RacePrompter.js
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 35 additions & 0 deletions src/usecases/RacingGame.js
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 20 additions & 0 deletions src/util/Format.js
Original file line number Diff line number Diff line change
@@ -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;
60 changes: 60 additions & 0 deletions src/util/Validation.js
Original file line number Diff line number Diff line change
@@ -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;