diff --git a/build.gradle.kts b/build.gradle.kts index 19a215a..2b3dd38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ group = "camp.nextstep.edu" version = "1.0-SNAPSHOT" kotlin { - jvmToolchain(21) + jvmToolchain(17) } repositories { diff --git a/src/main/kotlin/wordle/Application.kt b/src/main/kotlin/wordle/Application.kt new file mode 100644 index 0000000..6f5d9e7 --- /dev/null +++ b/src/main/kotlin/wordle/Application.kt @@ -0,0 +1,7 @@ +package wordle + +import java.time.LocalDate + +fun main() { + Game(LocalDate.now()).start() +} diff --git a/src/main/kotlin/wordle/Game.kt b/src/main/kotlin/wordle/Game.kt new file mode 100644 index 0000000..79cf128 --- /dev/null +++ b/src/main/kotlin/wordle/Game.kt @@ -0,0 +1,35 @@ +package wordle + +import wordle.domain.Stage +import wordle.domain.Word +import wordle.infra.FileDictionary +import wordle.view.Input +import wordle.view.Output +import java.time.LocalDate + +class Game(today: LocalDate) { + private val dictionary = FileDictionary() + private val answerSelector = TodayAnswerSelector(today) + private var stage = Stage(answer = dictionary.findAnswer(answerSelector)) + + fun start() { + Output.start() + while (stage.finished.not()) { + val word = readWord() + stage = stage.play(word) + Output.show(stage) + } + } + + private fun readWord(): Word { + while (true) { + try { + val word = Input.guess() + require(dictionary.hasWord(word)) { "존재하지 않는 단어입니다." } + return word + } catch (e: Exception) { + Output.error(e) + } + } + } +} diff --git a/src/main/kotlin/wordle/TodayAnswerSelector.kt b/src/main/kotlin/wordle/TodayAnswerSelector.kt new file mode 100644 index 0000000..2e99477 --- /dev/null +++ b/src/main/kotlin/wordle/TodayAnswerSelector.kt @@ -0,0 +1,12 @@ +package wordle + +import wordle.domain.AnswerSelector +import java.time.LocalDate + +class TodayAnswerSelector(private val today: LocalDate): AnswerSelector { + private val baseDate = LocalDate.of(2021, 6, 19) + + override fun findIndex(maxSize: Int): Int { + return today.toEpochDay().minus(baseDate.toEpochDay()).toInt() % maxSize + } +} diff --git a/src/main/kotlin/wordle/domain/AnswerSelector.kt b/src/main/kotlin/wordle/domain/AnswerSelector.kt new file mode 100644 index 0000000..3511b14 --- /dev/null +++ b/src/main/kotlin/wordle/domain/AnswerSelector.kt @@ -0,0 +1,5 @@ +package wordle.domain + +fun interface AnswerSelector { + fun findIndex(maxSize: Int): Int +} diff --git a/src/main/kotlin/wordle/domain/Dictionary.kt b/src/main/kotlin/wordle/domain/Dictionary.kt new file mode 100644 index 0000000..2daa863 --- /dev/null +++ b/src/main/kotlin/wordle/domain/Dictionary.kt @@ -0,0 +1,13 @@ +package wordle.domain + +interface Dictionary { + val words: List + + fun hasWord(word: Word): Boolean { + return words.contains(word.value) + } + + fun findAnswer(answerSelector: AnswerSelector): String { + return words[answerSelector.findIndex(words.size)] + } +} diff --git a/src/main/kotlin/wordle/domain/Stage.kt b/src/main/kotlin/wordle/domain/Stage.kt new file mode 100644 index 0000000..6051e73 --- /dev/null +++ b/src/main/kotlin/wordle/domain/Stage.kt @@ -0,0 +1,32 @@ +package wordle.domain + +/** + * 사용자가 하나의 정답을 가지고 진행하는 워들 한 판 + */ +data class Stage(val answer: String, val steps: List = listOf()) { + + enum class State { + PROGRESS, COMPLETE, FAIL + } + + val state: State = when { + steps.any { step -> step.isCorrect } -> State.COMPLETE + steps.size == 6 -> State.FAIL + else -> State.PROGRESS + } + + val finished = state != State.PROGRESS + + fun play(word: Word): Stage { + if (finished) return this + val step = Step(answer, word) + val newSteps = steps.toMutableList().apply { + add(step) + } + + return Stage( + answer = answer, + steps = newSteps, + ) + } +} diff --git a/src/main/kotlin/wordle/domain/Step.kt b/src/main/kotlin/wordle/domain/Step.kt new file mode 100644 index 0000000..142e95e --- /dev/null +++ b/src/main/kotlin/wordle/domain/Step.kt @@ -0,0 +1,49 @@ +package wordle.domain + +/** + * 사용자가 [Word]를 입력하여 정답과 비교한 결과를 가진 한 번의 단계 + */ +data class Step(val answer: String, val word: Word) { + + enum class Result { + CORRECT, + MISMATCH, + WRONG; + } + + val result: List + + init { + result = if (answer == word.value) { + List(5) { Result.CORRECT }.toList() + } else { + MutableList(5) { Result.WRONG }.apply { + fillCorrect(this) + fillMismatch(this) + } + } + } + + val isCorrect = result.all { it == Result.CORRECT } + + private fun fillCorrect(initResult: MutableList) { + word.value.forEachIndexed { index, letter -> + if (answer[index] == letter) { + initResult[index] = Result.CORRECT + } + } + } + + private fun fillMismatch(correctedResult: MutableList) { + val calculatedAnswer = + StringBuilder(answer.filterIndexed { index, _ -> correctedResult[index] != Result.CORRECT }) + + word.value.forEachIndexed { index, letter -> + if (correctedResult[index] != Result.CORRECT && calculatedAnswer.contains(letter)) { + correctedResult[index] = Result.MISMATCH + val foundIndex = calculatedAnswer.indexOf(letter) + calculatedAnswer.deleteAt(foundIndex) + } + } + } +} diff --git a/src/main/kotlin/wordle/domain/Word.kt b/src/main/kotlin/wordle/domain/Word.kt new file mode 100644 index 0000000..8cb9b74 --- /dev/null +++ b/src/main/kotlin/wordle/domain/Word.kt @@ -0,0 +1,22 @@ +package wordle.domain + +/** + * 사용자의 검증된 입력 단어 + */ +@JvmInline +value class Word private constructor(val value: String) { + + init { + require(value.matches(englishRegex)) { "영문만 입력해야합니다." } + require(value.length == 5) { "5글자여야 합니다." } + } + + constructor(value: String, isLowercase: Boolean = false) : + this(if (isLowercase) value else value.lowercase()) + + + companion object { + private val englishRegex = Regex("^[A-Za-z]*") + } + +} diff --git a/src/main/kotlin/wordle/infra/FileDictionary.kt b/src/main/kotlin/wordle/infra/FileDictionary.kt new file mode 100644 index 0000000..0faaadc --- /dev/null +++ b/src/main/kotlin/wordle/infra/FileDictionary.kt @@ -0,0 +1,11 @@ +package wordle.infra + +import wordle.domain.Dictionary +import java.io.File + +class FileDictionary: Dictionary { + private val PATH = "./src/main/resources" + private val FILE_NAME = "words.txt" + + override val words: List = File(PATH, FILE_NAME).readLines() +} diff --git a/src/main/kotlin/wordle/view/Input.kt b/src/main/kotlin/wordle/view/Input.kt new file mode 100644 index 0000000..fbe90cc --- /dev/null +++ b/src/main/kotlin/wordle/view/Input.kt @@ -0,0 +1,12 @@ +package wordle.view + +import wordle.domain.Word + +object Input { + + fun guess(): Word { + println("정답을 입력해 주세요.") + return Word(readln().trim()) + } + +} diff --git a/src/main/kotlin/wordle/view/Output.kt b/src/main/kotlin/wordle/view/Output.kt new file mode 100644 index 0000000..9ce3f23 --- /dev/null +++ b/src/main/kotlin/wordle/view/Output.kt @@ -0,0 +1,54 @@ +package wordle.view + +import wordle.domain.Stage +import wordle.domain.Step + +object Output { + + fun start() { + println( + """ + WORDLE을 6번 만에 맞춰 보세요. + 시도의 결과는 타일의 색 변화로 나타납니다. + """.trimIndent() + ) + } + + fun show(stage: Stage) { + when (stage.state) { + Stage.State.FAIL -> { + println("X/6") + showAllSteps(stage) + println("answer = ${stage.answer}") + } + + Stage.State.COMPLETE -> { + println("${stage.steps.size}/6") + showAllSteps(stage) + } + + Stage.State.PROGRESS -> { + showAllSteps(stage) + } + } + } + + fun error(e: Exception) { + println(e.message) + } + + private fun showAllSteps(stage: Stage) { + stage.steps.forEach { showStep(it) } + } + + private fun showStep(step: Step) { + println(step.result.joinToString("") { + when (it) { + Step.Result.CORRECT -> "\uD83D\uDFE9" + Step.Result.MISMATCH -> "\uD83D\uDFE8" + Step.Result.WRONG -> "⬜" + } + }) + } + +} diff --git a/src/test/kotlin/wordle/DictionaryTest.kt b/src/test/kotlin/wordle/DictionaryTest.kt new file mode 100644 index 0000000..61d54c7 --- /dev/null +++ b/src/test/kotlin/wordle/DictionaryTest.kt @@ -0,0 +1,43 @@ +package wordle + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import wordle.domain.Dictionary +import wordle.domain.Word + +class DictionaryTest { + + class MockDictionary(override val words: List) : Dictionary + + private val dictionary = MockDictionary(listOf("world", "angry", "hello")) + private val answerSelector = { _: Int -> 1 } + + @ParameterizedTest + @ValueSource(strings = ["asder", "wrfdx", "bljwq"]) + fun `단어가 존재하지 않으면 false`(word: String) { + //when + val result = dictionary.hasWord(Word(word)) + + //then + assertThat(result).isFalse() + } + + @ParameterizedTest + @ValueSource(strings = ["hello", "world", "angry"]) + fun `단어가 존재하면 true`(word: String) { + //when + val result = dictionary.hasWord(Word(word)) + + //then + assertThat(result).isTrue() + } + + @Test + fun `answerSelector 기준으로 단어를 추출한다`() { + val findTodayWord = dictionary.findAnswer(answerSelector) + + assertThat(findTodayWord).isEqualTo("angry") + } +} diff --git a/src/test/kotlin/wordle/StageTest.kt b/src/test/kotlin/wordle/StageTest.kt new file mode 100644 index 0000000..7b8a709 --- /dev/null +++ b/src/test/kotlin/wordle/StageTest.kt @@ -0,0 +1,71 @@ +package wordle + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import wordle.domain.Stage +import wordle.domain.Word + +internal class StageTest { + + @Test + fun `스테이지를 만들면 PROGRESS`() { + val stage = Stage("hello") + + assertAll( + { assertThat(stage.state).isEqualTo(Stage.State.PROGRESS) }, + { assertThat(stage.finished).isFalse() } + ) + } + + @Test + fun `스테이지가 play시 정답을 맞추면 Complete`() { + // given + val stage = Stage("hello") + + // when + val newStage = stage.play(Word("hello")) + + // then + assertAll( + { assertThat(newStage.state).isEqualTo(Stage.State.COMPLETE) }, + { assertThat(newStage.finished).isTrue() } + ) + } + + @Test + fun `스테이지가 play시 정답을 맞추지 못하면 Complete`() { + // given + val stage = Stage("hello") + + // when + val newStage = stage.play(Word("wrong")) + + // then + assertAll( + { assertThat(newStage.state).isEqualTo(Stage.State.PROGRESS) }, + { assertThat(newStage.finished).isFalse() } + ) + } + + @Test + fun `스테이지가 play시 6번 모두 정답을 맞추지 못하면 FAIL`() { + // given + val stage = Stage("hello") + + //when + val failedStage = stage + .play(Word("wrong")) + .play(Word("wrong")) + .play(Word("wrong")) + .play(Word("wrong")) + .play(Word("wrong")) + .play(Word("wrong")) + + // then + assertAll( + { assertThat(failedStage.state).isEqualTo(Stage.State.FAIL) }, + { assertThat(failedStage.finished).isTrue() } + ) + } +} diff --git a/src/test/kotlin/wordle/StepTest.kt b/src/test/kotlin/wordle/StepTest.kt new file mode 100644 index 0000000..ebcadf5 --- /dev/null +++ b/src/test/kotlin/wordle/StepTest.kt @@ -0,0 +1,79 @@ +package wordle + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import wordle.domain.Step +import wordle.domain.Word + +internal class StepTest { + + @Test + fun `완전히 동일한 단어일 경우 전부 CORRECT`() { + val answer = "hello" + val word = Word("hello") + + assertThat(Step(answer, word).result).isEqualTo(Array(5) { Step.Result.CORRECT }.toList()) + } + + @Test + fun `단어는 포함되어 있고 위치가 일치하면 CORRECT`() { + val answer = "hello" + val word = Word("helol") + + val resultCode = Step(answer, word).result + + // then + assertAll( + { assertThat(resultCode[0]).isEqualTo(Step.Result.CORRECT) }, + { assertThat(resultCode[2]).isEqualTo(Step.Result.CORRECT) }, + ) + } + + @Test + fun `단어는 포함되어 있으나 위치가 일치하지 않을경우 MISMATCH`() { + val answer = "hello" + val word = Word("helol") + + val resultCode = Step(answer, word).result + + // then + assertAll( + { assertThat(resultCode[3]).isEqualTo(Step.Result.MISMATCH) }, + { assertThat(resultCode[4]).isEqualTo(Step.Result.MISMATCH) }, + ) + } + + @Test + fun `단어는 포함되어 있으나 위치가 일치하지 않고 정답개수의 단어보다 입력 갯수가 더 많을 경우 일치한 횟수(왼쪽에서 오른쪽으로 계산)가 적으면 MISMATCH`() { + val answer = "hello" + val word = Word("heool") + + val resultCode = Step(answer, word).result + + // then + assertThat(resultCode[2]).isEqualTo(Step.Result.MISMATCH) + } + + @Test + fun `단어는 포함되어 있으나 위치가 일치하지 않고 정답개수의 단어보다 입력 갯수가 더 많을 경우 일치한 횟수(왼쪽에서 오른쪽으로 계산)가 많으면 WRONG`() { + val answer = "hello" + val word = Word("heool") + + val resultCode = Step(answer, word).result + + // then + assertThat(resultCode[3]).isEqualTo(Step.Result.WRONG) + } + + @Test + fun `단어는 포함조차 안되어있으면 WRONG`() { + val answer = "hello" + val word = Word("helok") + + val resultCode = Step(answer, word).result + + // then + assertThat(resultCode[4]).isEqualTo(Step.Result.WRONG) + } +} diff --git a/src/test/kotlin/wordle/WordTest.kt b/src/test/kotlin/wordle/WordTest.kt new file mode 100644 index 0000000..4bf0446 --- /dev/null +++ b/src/test/kotlin/wordle/WordTest.kt @@ -0,0 +1,36 @@ +package wordle + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import wordle.domain.Word + +internal class WordTest { + + @ParameterizedTest + @ValueSource(strings = ["-1", "1", " ", "w!", " ;fs"]) + fun `모두 영문으로 구성된다`(value: String) { + //when + val errorResponse = assertThrows { Word(value) } + + ///then + assertThat(errorResponse.message).isEqualTo("영문만 입력해야합니다.") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "hi", "h", "tttttt"]) + fun `5글자가 아니면 IllegalArgumentException 예외가 발생한다`(value: String) { + //when + val errorResponse = assertThrows { Word(value) } + + ///then + assertThat(errorResponse.message).isEqualTo("5글자여야 합니다.") + } + + @Test + fun `영문 대문자는 소문자로 치환된다`() { + assertThat(Word("Hello")).isEqualTo(Word("hello")) + } +}