Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5기]3주차 Wordle 과제 제출 - 치즈 #30

Open
wants to merge 45 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5d78aa1
docs(README.md): 기능 목록 작성
ycheese Jun 9, 2024
0f37271
feat: 게임 시작 입출력 클래스 생성
ycheese Jun 9, 2024
152e102
feat: 오늘의 정답 제공자 클래스 생성
ycheese Jun 9, 2024
a2f92be
feat: 사용자 입력과 정답 비교 로직 추가
ycheese Jun 9, 2024
e7d01ae
refactor: tile enum 추가
ycheese Jun 9, 2024
a20cbf5
feat: 게임 라운드 클래스 생성
ycheese Jun 9, 2024
ca477cc
refactor: answer 클래스의 책임을 round 클래스로 분리
ycheese Jun 9, 2024
2249453
feat: 라운드 별 결과에 따라 반복 로직 추가
ycheese Jun 9, 2024
8f522ab
feat: 입력값 검증 로직 추가
ycheese Jun 9, 2024
278f963
test: 입력값 검증 테스트 추가
ycheese Jun 9, 2024
e29c493
feat: 게임 진행 클래스 생성
ycheese Jun 9, 2024
ec19ce4
feat: 라운드 결과 갱신/출력 로직 추가
ycheese Jun 9, 2024
6d576d0
refactor: 메시지 출력을 Console 클래스로 이동
ycheese Jun 9, 2024
9ca1628
feat: 이전 라운드 결과 저장 및 출력
ycheese Jun 9, 2024
c06266b
refactor: printResult() 인자를 RoundResult 에서 String 으로 변경
ycheese Jun 9, 2024
e945ca1
feat: 오늘 날짜에 따라 정답 단어를 읽어오는 로직 추가
ycheese Jun 9, 2024
5c2c650
feat: 입력한 단어의 파일 포함 여부 검증 로직 추가
ycheese Jun 15, 2024
1dffe39
fix: YELLOW 타일 판단 기준 로직 수정
ycheese Jun 15, 2024
751cb85
refactor: 중복 로직 제거 및 변수명 변경
ycheese Jun 15, 2024
aba19f3
refactor: 메소드명을 직관적으로 변경
ycheese Jun 15, 2024
3b862be
refactor: 메소드명을 직관적으로 변경
ycheese Jun 15, 2024
e3592ae
test: Round 클래스 테스트 추가
ycheese Jun 15, 2024
c1b4bb9
feat: 게임 관련 상수를 관리하는 클래스 생성
ycheese Jun 15, 2024
bb20d2e
refactor: 전역 상수를 WorldGameConfig 클래스로 분리
ycheese Jun 15, 2024
5af5e3a
test: RoundResult 클래스 테스트 추가
ycheese Jun 15, 2024
52db4de
Update: 사용자 입력 오류 시 다시 입력받게 수정
ycheese Jun 15, 2024
c1944e5
refactor: 입력값 검증 로직 InputValidator 클래스로 분리
ycheese Jun 15, 2024
386e974
refactor: 단어 파일 출력 로직 WordFileReader 클래스로 분리
ycheese Jun 15, 2024
f59a5ef
refactor: 입력값 검증 로직 InputValidator 클래스로 분리
ycheese Jun 15, 2024
5e87483
refactor: 입력값 검증 로직 조건별 메소드 분리
ycheese Jun 15, 2024
c69c28a
refactor: 입력값 검증 로직 조건별 메소드 분리
ycheese Jun 15, 2024
26942b5
test: InputValidator 클래스 테스트 추가
ycheese Jun 15, 2024
d3e012a
refactor: 정답인지 검증하는 로직 가독성 개선
ycheese Jun 16, 2024
c7310bb
refactor: 코드 컨벤션 적용
ycheese Jun 16, 2024
a0e0221
test: WordFileReader 클래스 테스트 추가
ycheese Jun 16, 2024
8e0fcb2
refactor: 클래스 패키지 분리
ycheese Jun 16, 2024
f7ee01e
refactor: 여백 최적화
ycheese Jun 16, 2024
02fd48e
docs: README.md 수정
ycheese Jun 16, 2024
1f0c3a3
fix: 게임 종료 조건 체크 버그 수정
ycheese Jun 24, 2024
30b37aa
refactor: 현재 라운드 개념을 Round 클래스로 이동
ycheese Jun 24, 2024
e54383b
chore: 테스트 깨짐 방지를 위한 생성자 추가
ycheese Jun 24, 2024
0326777
refactor: Answer의 countPerCharacter 복사본 가져오는 방식을 Answer 클래스에 위임
ycheese Jun 24, 2024
8c5cc2c
fix: 잘못된 클래스 참조 수정
ycheese Jun 24, 2024
a94f775
refactor: static 변수 초기화 시점 변경
ycheese Jun 28, 2024
71d346d
fix: 실패하는 테스트 케이스 추가 및 로직 수정
ycheese Jun 28, 2024
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
118 changes: 30 additions & 88 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,89 +1,31 @@
# 미션 - 워들

## 🔍 진행 방식

- 미션은 **과제 진행 요구 사항**, **기능 요구 사항**, **프로그래밍 요구 사항** 세 가지로 구성되어 있다.
- 세 개의 요구 사항을 만족하기 위해 노력한다. 특히 기능을 구현하기 전에 기능 목록을 만들고, 기능 단위로 커밋 하는 방식으로 진행한다.
- **기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다.**

---

## 🚀 기능 요구 사항

선풍적인 인기를 끌었던 영어 단어 맞추기 게임이다.

- 6x5 격자를 통해서 5글자 단어를 6번 만에 추측한다.
- 플레이어가 답안을 제출하면 프로그램이 정답과 제출된 단어의 각 알파벳 종류와 위치를 비교해 판별한다.
- 판별 결과는 흰색의 타일이 세 가지 색(초록색/노란색/회색) 중 하나로 바뀌면서 표현된다.
- 맞는 글자는 초록색, 위치가 틀리면 노란색, 없으면 회색
- 두 개의 동일한 문자를 입력하고 그중 하나가 회색으로 표시되면 해당 문자 중 하나만 최종 단어에 나타난다.
- 정답과 답안은 `words.txt`에 존재하는 단어여야 한다.
- 정답은 매일 바뀌며 ((현재 날짜 - 2021년 6월 19일) % 배열의 크기) 번째의 단어이다.

### 입출력 요구 사항

#### 실행 결과 예시

```
WORDLE을 6번 만에 맞춰 보세요.
시도의 결과는 타일의 색 변화로 나타납니다.
정답을 입력해 주세요.
hello

⬜⬜🟨🟩⬜

정답을 입력해 주세요.
label

⬜⬜🟨🟩⬜
🟨⬜⬜⬜🟩

정답을 입력해 주세요.
spell

⬜⬜🟨🟩⬜
🟨⬜⬜⬜🟩
🟩🟩⬜🟩🟩

정답을 입력해 주세요.
spill

4/6

⬜⬜🟨🟩⬜
🟨⬜⬜⬜🟩
🟩🟩⬜🟩🟩
🟩🟩🟩🟩🟩
```

---

## 🎯 프로그래밍 요구 사항

- JDK 21 버전에서 실행 가능해야 한다.
- 프로그램 실행의 시작점은 `Application`의 `main()`이다.
- `build.gradle` 파일은 변경할 수 없으며, **제공된 라이브러리 이외의 외부 라이브러리는 사용하지 않는다.**
- 프로그램 종료 시 `System.exit()`를 호출하지 않는다.
- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 등의 이름을 바꾸거나 이동하지 않는다.
- 자바 코드 컨벤션을 지키면서 프로그래밍한다.
- 기본적으로 [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)를 원칙으로 한다.
- 단, 들여쓰기는 '2 spaces'가 아닌 '4 spaces'로 한다.
- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다.
- 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
- 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다.
- 3항 연산자를 쓰지 않는다.
- 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만들어라.
- JUnit 5와 AssertJ를 이용하여 정리한 기능 목록이 정상적으로 작동하는지 테스트 코드로 확인한다.
- 테스트 도구 사용법이 익숙하지 않다면 아래 문서를 참고하여 학습한 후 테스트를 구현한다.
- [JUnit 5 User Guide](https://junit.org/junit5/docs/current/user-guide)
- [AssertJ User Guide](https://assertj.github.io/doc)
- [AssertJ Exception Assertions](https://www.baeldung.com/assertj-exception-assertion)
- [Guide to JUnit 5 Parameterized Tests](https://www.baeldung.com/parameterized-tests-junit-5)
- 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다.
- 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현한다.
- else 예약어를 쓰지 않는다.
- else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다.
- 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다.
- 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
- 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다.
- 힌트: MVC 패턴 기반으로 구현한 후, View와 Controller를 제외한 Model에 대한 단위 테스트 추가에 집중한다.
## 페어
- 치즈
- kr

## 기능 목록
### 입출력
- [X] 게임 시작 메세지를 콘솔에 출력한다.
- [X] 플레이어는 한 번의 입력마다 5자의 영문을 입력한다.
- [X] 플레이어가 입력한 결과에 따라 문자별로 5개의 색상으로 표현한다.
- ⬜️ 회색: 정답에 존재하지 않는 문자
- 🟨 노랑색: 정답 내에 존재하는 문자이나, 위치가 다름
- 🟩 초록색: 정답에 해당하는 문자
- [X] 표현한 색상을 직전 결과에 스택 형식으로 쌓는다.
### 게임 진행
- [X] 플레이어의 턴(입력) 횟수는 6회로 제한한다.
- [X] 정답을 생성한다.
- [X] 답안과 정답이 같은지 비교한다.
- [X] 주어진 입력 횟수 내에 정답을 맞추면 게임을 종료한다.
- [X] 정답을 맞추지 못하면 횟수와 콘솔 내용을 초기화하고 재시작한다.
### 파일
- [X] 정답은 words.txt 에서 ((현재 날짜 - 2021년 6월 19일) % 배열의 크기) 번째의 단어를 가져온다.
- [X] 플레이어의 답안이 words.txt 에 존재하는지 확인한다.

## 테스트 시나리오
- [X] 사용자의 입력이 5자의 영문이 아니면 예외가 발생한다.
- [X] 오늘의 정답 단어 인덱스는 ((현재 날짜 - 2021년 6월 19일) % 배열의 크기) 이다.
- [X] 사용자의 입력이 words.txt 에 존재하지 않으면 예외가 발생한다.
- [X] 문자와 위치가 일치하면 GREEN 상태를 반환한다.
- [X] 문자는 일치하지만 위치가 다르면 YELLOW 상태를 반환한다.
- [X] 문자가 일치하지 않으면 GRAY 상태를 반환한다.
8 changes: 8 additions & 0 deletions src/main/java/kr/co/wordle/Application.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kr.co.wordle;

public class Application {
public static void main(String[] args) {
WordleGame wordleGame = new WordleGame();
wordleGame.start();
}
}
46 changes: 46 additions & 0 deletions src/main/java/kr/co/wordle/WordleGame.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kr.co.wordle;

import kr.co.wordle.domain.Answer;
import kr.co.wordle.domain.Round;
import kr.co.wordle.view.Console;

import static kr.co.wordle.config.WordleGameConfig.MAX_ROUND;

public class WordleGame {
private final Console console;
private final Answer answer;
private final StringBuilder roundResults;

private int currentRound;

public WordleGame() {
this.console = new Console();
this.answer = new Answer();
this.roundResults = new StringBuilder();
this.currentRound = 0;
}

public void start() {
console.init();
Round round = null;
while (isRoundInProgress(round, roundResults)) {
ycheese marked this conversation as resolved.
Show resolved Hide resolved
currentRound++;
String input = console.userInput();
round = new Round(input);
roundResults.append(round.roundResult(answer)).append("\n");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input, answer 둘 다 Round 생성 시점에 넣어주도록 할 수도 있고, 둘 다 메서드 호출 시 넘겨주도록 구현할 수도 있을 것 같아요.
Round 객체 생성 시 input만 넣어주고, 메서드 호출 시 answer를 넘겨주도록 설계하신 이유가 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round가 answer를 갖는 형태가 어색하다고 생각했어요.
반대로 Round 하나 당 사용자 입력을 한 번 받으니까 input은 라운드에 속해있다는 관점으로 설계했습니다.
이렇게 정리하면서 보니까 어색한 느낌이 들기도 하네요 !
리팩토링 포인트로 붙잡아두겠습니다 ... 🤔

console.printRoundResult(roundResults);
}
}

private boolean isRoundInProgress(Round round, StringBuilder roundResults) {
if (round == null) {
return true;
}
if (round.isFinished()) {
console.printRound(currentRound, MAX_ROUND);
console.printRoundResult(roundResults);
return false;
}
return currentRound <= MAX_ROUND;
ycheese marked this conversation as resolved.
Show resolved Hide resolved
}
}
10 changes: 10 additions & 0 deletions src/main/java/kr/co/wordle/config/WordleGameConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.co.wordle.config;

public class WordleGameConfig {
public static final String WORDS_FILE_PATH = "words.txt";
public static final int WORD_LENGTH = 5;
public static final int MAX_ROUND = 6;

private WordleGameConfig() {
}
}
35 changes: 35 additions & 0 deletions src/main/java/kr/co/wordle/domain/Answer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package kr.co.wordle.domain;

import kr.co.wordle.domain.provider.AnswerProvider;

public class Answer {

private final String value;
private final int[] countPerCharacter = new int[26];

public Answer() {
this.value = AnswerProvider.todayAnswer();
countPerCharacter();
}

protected Answer(String value) {
this.value = value;
countPerCharacter();
}

private void countPerCharacter() {
char[] chars = value.toCharArray();
for (char ch : chars) {
countPerCharacter[ch - 'a']++;
}
}

public int[] getCountPerCharacter() {
return countPerCharacter;
}

public char charAt(int index) {
return value.charAt(index);
}

}
45 changes: 45 additions & 0 deletions src/main/java/kr/co/wordle/domain/Round.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package kr.co.wordle.domain;

import kr.co.wordle.support.InputValidator;
import java.util.Arrays;

import static kr.co.wordle.config.WordleGameConfig.WORD_LENGTH;

public class Round {
ycheese marked this conversation as resolved.
Show resolved Hide resolved

private final String input;
private final RoundResult roundResult;

public Round(String input) {
if (InputValidator.isNotValid(input)) {
throw new IllegalArgumentException();
}
this.input = input;
this.roundResult = new RoundResult();
}

public String roundResult(Answer answer) {
char[] inputChars = input.toCharArray();
int[] countPerCharacter = Arrays.copyOf(answer.getCountPerCharacter(), 26);
ycheese marked this conversation as resolved.
Show resolved Hide resolved
for (int i = 0; i< WORD_LENGTH; i++) {
Tile key = getTile(countPerCharacter, answer.charAt(i), inputChars[i]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

딱구님과 제가 이쪽 로직에서 countPerCharacter가 Answer의 멤버로 포함되어 있고, 단순히 getter만 제공하고 있는 구조라서 counterPerCharacter가 Answer 내부에 있는게 옳은 방향인가 고민하다가 클래스를 분리했었는데요.
이 부분을 어떤 논의 과정으로 설계, 구현하셨는지 궁급합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 깊게 고민하지 않았는데, 말씀을 들어보니 Answer 내부에 countPerCharacter가 있는 게 아주 매끄럽지는 못하군요 🤔
저도 어떻게 풀어나가야 할 지 고민해봤는데 명쾌한 답을 아직 못 찾았습니다 ..
싸무엘님과 딱구님은 어떤 과정으로 클래스를 분리하셨을까요 ~?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희도 동일한 부분에서 어색하다고 느껴서 분리의 목적으로 countPerCharacter 에 관련된 책임을 갖는 Counter라는 클래스를 만들었어요.
단어의 문자를 비교할 때 count 관련한 기능은 해당 클래스가 담당하도록 했고, 비즈니스 로직에서 Answer와 Counter를 조합해서 문제를 풀어나가면 되지 않을까 생각했던 것 같아요.
100% 명쾌하게 해결한 것인지는 모르겠어서 저도 고민이 더 필요한 부분이긴 해요 ㅎㅎ 다른 분들의 문제 해결 과정도 궁금해지네요!

roundResult.update(key);
}
return roundResult.toString();
}

public Tile getTile(int[] counts, char source, char target) {
if(counts[target - 'a'] == 0) {
return Tile.GRAY;
}
if(source != target) {
return Tile.YELLOW;
}
counts[target - 'a']--;
return Tile.GREEN;
}

public boolean isFinished() {
return roundResult.isAllGreen();
}
}
30 changes: 30 additions & 0 deletions src/main/java/kr/co/wordle/domain/RoundResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package kr.co.wordle.domain;

import java.util.EnumMap;
import java.util.Map;

import static kr.co.wordle.config.WordleGameConfig.WORD_LENGTH;

public class RoundResult {
private final Map<Tile, Integer> countPerTile;
private final StringBuilder result;

public RoundResult() {
this.countPerTile = new EnumMap<>(Tile.class);
result = new StringBuilder();
}

public void update(Tile tile) {
countPerTile.compute(tile, (k, v) -> (v == null) ? 1 : v + 1);
result.append(tile);
}

public boolean isAllGreen() {
return countPerTile.getOrDefault(Tile.GREEN, 0) == WORD_LENGTH;
}

@Override
public String toString() {
return result.toString();
}
}
18 changes: 18 additions & 0 deletions src/main/java/kr/co/wordle/domain/Tile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package kr.co.wordle.domain;

public enum Tile {
ycheese marked this conversation as resolved.
Show resolved Hide resolved
GREEN("🟩"),
YELLOW("🟨"),
GRAY("⬜️");

private final String text;

Tile(String text) {
this.text = text;
}

@Override
public String toString() {
return text;
}
}
27 changes: 27 additions & 0 deletions src/main/java/kr/co/wordle/domain/provider/AnswerProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.co.wordle.domain.provider;

import kr.co.wordle.support.WordFileReader;

import java.time.LocalDate;
import java.time.Period;
import java.util.List;

public class AnswerProvider {

private static final LocalDate REFERENCE_DATE = LocalDate.of(2021, 6, 19);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WordleGameConfig 클래스가 게임 규칙 관련한 상수를 모아놓은 역할이라고 이해했어요.
그래서 이 상수도 게임 규칙 관련한거라 WordleGameConfig 에 포함되면 좋을 것 같다고 생각하는데, 혹시 WordleGameConfig에서 관리하지 않고 따로 분리하신 이유가 있을까요?? 👀

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다 ! 👍 정확히 표현하자면 WorldeGameConfig는 클라이언트 단에 즉각적으로 변화가 생길 수 있는 게임 규칙의 역할로 설계했어요.

  • 단어 파일(정답) 교체
  • 라운드(기회) 수 조정 등

이 상수도 고민이 많았지만, 오늘 날짜와 차이를 비교해서 정답을 도출해내는 도구에 국한된다고 생각해서 AnswerProvider에 있는게 맞다고 생각했습니다 !

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 저는 이런 부분들이 모두 게임의 규칙이라고만 생각했었는데,, 구체적으로 답변 주셔서 감사합니다 😄


private AnswerProvider() {
}

public static String todayAnswer() {
List<String> words = WordFileReader.readWordsInFile();
int dayDiff = dayDiff();
return words.get(dayDiff % words.size());
}

private static int dayDiff() {
LocalDate currentDate = LocalDate.now();
Period period = Period.between(REFERENCE_DATE, currentDate);
return period.getDays();
ycheese marked this conversation as resolved.
Show resolved Hide resolved
}
}
34 changes: 34 additions & 0 deletions src/main/java/kr/co/wordle/support/InputValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package kr.co.wordle.support;

import java.util.List;

import static kr.co.wordle.config.WordleGameConfig.WORD_LENGTH;

public class InputValidator {

private static final List<String> words = WordFileReader.readWordsInFile();
ycheese marked this conversation as resolved.
Show resolved Hide resolved

private InputValidator() {
}

public static boolean isNotValid(String input) {
if (input == null) {
return true;
}
if (input.length() != WORD_LENGTH) {
return true;
}
if (isNotAllAlphabet(input)) {
return true;
}
return isNotInWords(input);
}

private static boolean isNotAllAlphabet(String input) {
return input.chars().anyMatch(ch -> ch < 'a' || ch > 'z');
}

private static boolean isNotInWords(String input) {
return !words.contains(input);
}
}
Loading