diff --git a/build.gradle.kts b/build.gradle.kts index e78e72956..14df1ed71 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ repositories { dependencies { testImplementation("org.junit.jupiter", "junit-jupiter", "5.8.2") testImplementation("org.assertj", "assertj-core", "3.22.0") - testImplementation("io.kotest", "kotest-runner-junit5", "5.2.3") + testImplementation("io.kotest", "kotest-runner-junit5", "5.6.1") } tasks { diff --git a/src/main/kotlin/MineSweeperApplication.kt b/src/main/kotlin/MineSweeperApplication.kt new file mode 100644 index 000000000..99c7c3ec4 --- /dev/null +++ b/src/main/kotlin/MineSweeperApplication.kt @@ -0,0 +1,5 @@ +import controller.MineSweeperController + +fun main() { + MineSweeperController().start() +} diff --git a/src/main/kotlin/README.md b/src/main/kotlin/README.md new file mode 100644 index 000000000..1954eb6a5 --- /dev/null +++ b/src/main/kotlin/README.md @@ -0,0 +1,20 @@ +# 지뢰 찾기 + +## 기능 요구사항 + +- 지뢰 찾기를 변형한 프로그램을 구현한다. + +- 높이와 너비, 지뢰 개수를 입력받을 수 있다. +- 지뢰는 눈에 잘 띄는 것으로 표기한다. +- 지뢰는 가급적 랜덤에 가깝게 배치한다. + +## 기능 목록 + +[x] 정해진 높이와 너비 내에서 랜덤으로 지뢰 위치를 지정할 수 있다. +[x] 지뢰 위치 정보에 맞는 지뢰판을 생성할 수 있다. +[x] 정해진 높이와 너비 내의 지뢰판을 생성할 수 있다. + +## 책임 + +1. 지뢰를 배치해라 -> `landMineGenerator.generate()` +2. 랜덤으로 지뢰 위치를 결정하라 -> `MineLocationStrategy.locations()` \ No newline at end of file diff --git a/src/main/kotlin/controller/MineSweeperController.kt b/src/main/kotlin/controller/MineSweeperController.kt new file mode 100644 index 000000000..7b5cfb4ae --- /dev/null +++ b/src/main/kotlin/controller/MineSweeperController.kt @@ -0,0 +1,20 @@ +package controller + +import domain.MineBoard +import view.InputView +import view.OutputView + +class MineSweeperController( + private val inputView: InputView = InputView, + private val outputView: OutputView = OutputView +) { + + fun start() { + val boardSize = inputView.requestBoardSize() + val mineCount = inputView.requestCountOfMine() + + outputView.printStartGame() + val mineBoard = MineBoard(boardSize, mineCount) + outputView.printMineBoard(mineBoard) + } +} diff --git a/src/main/kotlin/domain/BoardInfo.kt b/src/main/kotlin/domain/BoardInfo.kt new file mode 100644 index 000000000..e806006b0 --- /dev/null +++ b/src/main/kotlin/domain/BoardInfo.kt @@ -0,0 +1,9 @@ +package domain + +data class BoardInfo( + val layout: Layout +) { + operator fun get(y: Int): Row { + return layout[y] + } +} diff --git a/src/main/kotlin/domain/BoardInfoGenerator.kt b/src/main/kotlin/domain/BoardInfoGenerator.kt new file mode 100644 index 000000000..7a49c6c4d --- /dev/null +++ b/src/main/kotlin/domain/BoardInfoGenerator.kt @@ -0,0 +1,14 @@ +package domain + +class BoardInfoGenerator( + private val boardSize: BoardSize, + private val mineCount: Int, + private val mineLocationStrategy: MineLocationStrategy = RandomMineLocationStrategy() +) { + + fun generate(): BoardInfo { + val mineLocations = mineLocationStrategy.generateMineLocations(boardSize, mineCount) + val layout = mineLocations.layoutWithMines(boardSize) + return BoardInfo(layout) + } +} diff --git a/src/main/kotlin/domain/BoardSize.kt b/src/main/kotlin/domain/BoardSize.kt new file mode 100644 index 000000000..0d5e06e3d --- /dev/null +++ b/src/main/kotlin/domain/BoardSize.kt @@ -0,0 +1,14 @@ +package domain + +data class BoardSize( + val width: Int, + val height: Int +) { + init { + require(width > 0) { "너비는 0보다 커야 합니다." } + require(height > 0) { "높이는 0보다 커야 합니다." } + } + + val area: Int + get() = width * height +} diff --git a/src/main/kotlin/domain/Cell.kt b/src/main/kotlin/domain/Cell.kt new file mode 100644 index 000000000..b53628bc0 --- /dev/null +++ b/src/main/kotlin/domain/Cell.kt @@ -0,0 +1,10 @@ +package domain + +data class Cell( + val status: CellStatus +) + +enum class CellStatus { + EMPTY, + MINE +} diff --git a/src/main/kotlin/domain/Layout.kt b/src/main/kotlin/domain/Layout.kt new file mode 100644 index 000000000..f1e3fe9a8 --- /dev/null +++ b/src/main/kotlin/domain/Layout.kt @@ -0,0 +1,19 @@ +package domain + +data class Layout( + private val boardSize: BoardSize +) { + val rows: List = List(boardSize.height) { + Row(List(boardSize.width) { Cell(CellStatus.EMPTY) }.toMutableList()).copy() + } + + init { + check(rows.size == boardSize.height) { + "Layout 의 row 개수는 board 의 높이와 같아야합니다. [row size: ${rows.size} ]" + } + } + + operator fun get(y: Int): Row { + return rows[y].copy() + } +} diff --git a/src/main/kotlin/domain/MineBoard.kt b/src/main/kotlin/domain/MineBoard.kt new file mode 100644 index 000000000..f562a13e0 --- /dev/null +++ b/src/main/kotlin/domain/MineBoard.kt @@ -0,0 +1,16 @@ +package domain + +data class MineBoard( + val boardSize: BoardSize, + val mineCount: Int, + val boardInfoGenerator: BoardInfoGenerator = BoardInfoGenerator(boardSize, mineCount) +) { + val info: BoardInfo by lazy { + boardInfoGenerator.generate() + } + init { + require(boardSize.area >= mineCount) { + "지뢰판의 크기보다 지뢰의 개수가 더 많습니다. [지뢰 개수: $mineCount]" + } + } +} diff --git a/src/main/kotlin/domain/MineLocationStrategy.kt b/src/main/kotlin/domain/MineLocationStrategy.kt new file mode 100644 index 000000000..0a76bfa9f --- /dev/null +++ b/src/main/kotlin/domain/MineLocationStrategy.kt @@ -0,0 +1,5 @@ +package domain + +interface MineLocationStrategy { + fun generateMineLocations(boardSize: BoardSize, mineCount: Int): MineLocations +} diff --git a/src/main/kotlin/domain/MineLocations.kt b/src/main/kotlin/domain/MineLocations.kt new file mode 100644 index 000000000..d22ce4219 --- /dev/null +++ b/src/main/kotlin/domain/MineLocations.kt @@ -0,0 +1,15 @@ +package domain + +data class MineLocations( + val points: Set +) { + constructor(vararg point: Point) : this(points = point.toSet()) + + fun layoutWithMines(boardSize: BoardSize): Layout { + val layout = Layout(boardSize) + points.forEach { point -> + layout[point.y][point.x] = Cell(CellStatus.MINE) + } + return layout + } +} diff --git a/src/main/kotlin/domain/Point.kt b/src/main/kotlin/domain/Point.kt new file mode 100644 index 000000000..5a1b7e629 --- /dev/null +++ b/src/main/kotlin/domain/Point.kt @@ -0,0 +1,6 @@ +package domain + +data class Point( + val y: Int, + val x: Int +) diff --git a/src/main/kotlin/domain/RandomMineLocationStrategy.kt b/src/main/kotlin/domain/RandomMineLocationStrategy.kt new file mode 100644 index 000000000..c37ed0960 --- /dev/null +++ b/src/main/kotlin/domain/RandomMineLocationStrategy.kt @@ -0,0 +1,19 @@ +package domain + +class RandomMineLocationStrategy : MineLocationStrategy { + override fun generateMineLocations(boardSize: BoardSize, mineCount: Int): MineLocations { + val locations: MutableSet = mutableSetOf() + + while (locations.size < mineCount) { + locations.add(randomPoint(boardSize)) + } + + return MineLocations(locations.toSet()) + } + + private fun randomPoint(boardSize: BoardSize): Point { + val randomY = (0 until boardSize.height).random() + val randomX = (0 until boardSize.width).random() + return Point(randomY, randomX) + } +} diff --git a/src/main/kotlin/domain/Row.kt b/src/main/kotlin/domain/Row.kt new file mode 100644 index 000000000..6295af9d3 --- /dev/null +++ b/src/main/kotlin/domain/Row.kt @@ -0,0 +1,18 @@ +package domain + +data class Row( + private val _values: MutableList +) { + val values: List + get() = _values.toList() + val size: Int + get() = _values.size + + operator fun get(x: Int): Cell { + return _values[x] + } + + operator fun set(x: Int, value: Cell) { + _values[x] = value + } +} diff --git a/src/main/kotlin/view/InputView.kt b/src/main/kotlin/view/InputView.kt new file mode 100644 index 000000000..99a96e2db --- /dev/null +++ b/src/main/kotlin/view/InputView.kt @@ -0,0 +1,31 @@ +package view + +import domain.BoardSize +import java.lang.IllegalArgumentException + +object InputView { + fun requestBoardSize(): BoardSize { + val height = requestHeight() + val width = requestWidth() + + return BoardSize(width, height) + } + + private fun requestHeight(): Int { + println("높이를 입력하세요.") + return inputWithInt() + } + + private fun requestWidth(): Int { + println("\n너비를 입력하세요.") + return inputWithInt() + } + + private fun inputWithInt() = readln().toIntOrNull() ?: throw IllegalArgumentException("정수를 입력해주세요.") + + fun requestCountOfMine(): Int { + println("\n지뢰는 몇 개인가요?") + + return readln().toInt() + } +} diff --git a/src/main/kotlin/view/OutputView.kt b/src/main/kotlin/view/OutputView.kt new file mode 100644 index 000000000..96a1ef925 --- /dev/null +++ b/src/main/kotlin/view/OutputView.kt @@ -0,0 +1,32 @@ +package view + +import domain.CellStatus +import domain.MineBoard +import domain.Row + +object OutputView { + const val MINE = "C " + const val EMPTY = "* " + + fun printStartGame() { + println() + println("지뢰찾기 게임 시작") + } + + fun printMineBoard(mineBoard: MineBoard) { + mineBoard.info.layout.rows.forEach { row -> + print(row) + println() + } + } + + private fun print(row: Row) { + for (cell in row.values) { + if (cell.status == CellStatus.MINE) { + print(MINE) + } else { + print(EMPTY) + } + } + } +} diff --git a/src/test/kotlin/domain/BoardSizeTest.kt b/src/test/kotlin/domain/BoardSizeTest.kt new file mode 100644 index 000000000..ba08b9a24 --- /dev/null +++ b/src/test/kotlin/domain/BoardSizeTest.kt @@ -0,0 +1,15 @@ +package domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.inspectors.forAll + +class BoardSizeTest : FunSpec({ + + test("width 와 height 가 0보다 크지 않을 경우 예외가 발생한다.") { + listOf(Pair(0, 0), Pair(3, 0), Pair(0, 2)) + .forAll { + shouldThrow { BoardSize(it.first, it.second) } + } + } +}) diff --git a/src/test/kotlin/domain/FixedMineLocationStrategy.kt b/src/test/kotlin/domain/FixedMineLocationStrategy.kt new file mode 100644 index 000000000..32c360778 --- /dev/null +++ b/src/test/kotlin/domain/FixedMineLocationStrategy.kt @@ -0,0 +1,12 @@ +package domain + +class FixedMineLocationStrategy( + private val mineLocations: MineLocations +) : MineLocationStrategy { + override fun generateMineLocations(boardSize: BoardSize, mineCount: Int): MineLocations { + require(boardSize.area >= mineCount) { + "지뢰판의 크기보다 지뢰의 개수가 더 많습니다. [지뢰 개수: $mineCount]" + } + return mineLocations + } +} diff --git a/src/test/kotlin/domain/MineBoardGeneratorTest.kt b/src/test/kotlin/domain/MineBoardGeneratorTest.kt new file mode 100644 index 000000000..ce53b9855 --- /dev/null +++ b/src/test/kotlin/domain/MineBoardGeneratorTest.kt @@ -0,0 +1,50 @@ +package domain + +import io.kotest.core.spec.style.FunSpec +import io.kotest.inspectors.forAll +import io.kotest.matchers.shouldBe + +class MineBoardGeneratorTest : FunSpec({ + test("지뢰 위치 정보에 맞는 지뢰판을 생성할 수 있다.") { + // given + val width = 5 + val height = 5 + val boardSize = BoardSize(width, height) + val mineCount = 2 + val mineLocations = MineLocations(Point(1, 1), Point(1, 2)) + val boardInfoGenerator = BoardInfoGenerator( + boardSize, + mineCount, + FixedMineLocationStrategy(mineLocations) + ) + + // when + val actual = boardInfoGenerator.generate() + + // then + mineLocations.points.forAll { + actual[it.y][it.x].status shouldBe CellStatus.MINE + } + } + + test("정해진 높이와 너비 내의 지뢰판을 생성할 수 있다.") { + // given + val width = 5 + val height = 5 + val boardSize = BoardSize(width, height) + val mineCount = 3 + val boardInfoGenerator = BoardInfoGenerator( + boardSize, + mineCount, + ) + + // when + val actual = boardInfoGenerator.generate() + + // then + actual.layout.rows.size shouldBe height + actual.layout.rows.forAll { + it.size shouldBe width + } + } +}) diff --git a/src/test/kotlin/domain/MineBoardTest.kt b/src/test/kotlin/domain/MineBoardTest.kt new file mode 100644 index 000000000..e36f71697 --- /dev/null +++ b/src/test/kotlin/domain/MineBoardTest.kt @@ -0,0 +1,18 @@ +package domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class MineBoardTest : FunSpec({ + + test("지뢰판 사이즈보다 많은 지뢰 개수가 들어올 경우 예외가 발생한다.") { + // given + val boardSize = BoardSize(5, 5) + val mineCount = 100 + + // when, then + shouldThrow { MineBoard(boardSize, mineCount) } + .also { it.message shouldBe "지뢰판의 크기보다 지뢰의 개수가 더 많습니다. [지뢰 개수: $mineCount]" } + } +}) diff --git a/src/test/kotlin/domain/RandomMineLocationStrategyTest.kt b/src/test/kotlin/domain/RandomMineLocationStrategyTest.kt new file mode 100644 index 000000000..95605a3a9 --- /dev/null +++ b/src/test/kotlin/domain/RandomMineLocationStrategyTest.kt @@ -0,0 +1,26 @@ +package domain + +import io.kotest.core.spec.style.FunSpec +import io.kotest.inspectors.forAll +import io.kotest.matchers.ints.shouldBeInRange + +class RandomMineLocationStrategyTest : FunSpec({ + + test("정해진 높이와 너비 내에서 랜덤으로 지뢰 위치를 지정할 수 있다.") { + // given + val strategy = RandomMineLocationStrategy() + val width = 5 + val height = 5 + val boardSize = BoardSize(width, height) + val mineCount = 3 + + // when + val actual = strategy.generateMineLocations(boardSize, mineCount) + + // then + actual.points.forAll { + it.x.shouldBeInRange(0 until width) + it.y.shouldBeInRange(0 until height) + } + } +})