diff --git a/src/main/kotlin/minesweeper/MineSweeper.kt b/src/main/kotlin/minesweeper/MineSweeper.kt new file mode 100644 index 000000000..59444691b --- /dev/null +++ b/src/main/kotlin/minesweeper/MineSweeper.kt @@ -0,0 +1,28 @@ +package minesweeper + +import minesweeper.domain.MineMapGenerator +import minesweeper.domain.MineMapMeta +import minesweeper.domain.PositionGenerator +import minesweeper.view.InputView +import minesweeper.view.OutputView + +object MineSweeper { + fun drawMap() { + val mineMapMeta = MineMapMeta( + height = InputView.getHeight(), + width = InputView.getWidth(), + mineCount = InputView.getMineCount() + ) + val positionGenerator = PositionGenerator(mineMapMeta) + val minePositions = positionGenerator.generateMinePositions() + val emptyPositions = positionGenerator.generateEmptyPositions(minePositions) + val mineMap = MineMapGenerator.generate(minePositions, emptyPositions) + + OutputView.printGameStartMsg() + OutputView.printMineMap(mineMapMeta, mineMap) + } +} + +fun main() { + MineSweeper.drawMap() +} diff --git a/src/main/kotlin/minesweeper/README.md b/src/main/kotlin/minesweeper/README.md new file mode 100644 index 000000000..c30d1c079 --- /dev/null +++ b/src/main/kotlin/minesweeper/README.md @@ -0,0 +1,10 @@ +# README + +## Step1 구현 리스트 +- [x] `InputView` 생성: 사용자로부터 높이와 너비, 지뢰 개수를 입력받고 DTO 객체로 변환한다. +- [x] 지뢰판 생성을 위한 객체들을 구상하고, 관련해 테스트를 구현한다. + - [x] 지뢰판 생성을 위한 객체를 생성한다. (InputView로 받은 DTO 객체 활용) + - [x] 지뢰판 위치 정보를 프로퍼티로 가진 DTO를 생성한다. + - [x] 지뢰와 지뢰가 아닌 칸을 구분하기 위한 문자 및 클래스를 생성한다. + - [x] 지뢰는 주어진 지뢰 개수만큼 랜덤으로 배치한다. +- [x] `OutputView` 생성: 생성된 지뢰판을 출력한다. diff --git a/src/main/kotlin/minesweeper/domain/Cell.kt b/src/main/kotlin/minesweeper/domain/Cell.kt new file mode 100644 index 000000000..9644b2437 --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/Cell.kt @@ -0,0 +1,9 @@ +package minesweeper.domain + +data class Cell( + val state: CellState +) + +fun Cell.getStateSymbol(): String { + return this.state.symbol +} diff --git a/src/main/kotlin/minesweeper/domain/CellState.kt b/src/main/kotlin/minesweeper/domain/CellState.kt new file mode 100644 index 000000000..3757adc7e --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/CellState.kt @@ -0,0 +1,6 @@ +package minesweeper.domain + +enum class CellState(val symbol: String = "") { + MINE("*"), + EMPTY("C"); +} diff --git a/src/main/kotlin/minesweeper/domain/MineMap.kt b/src/main/kotlin/minesweeper/domain/MineMap.kt new file mode 100644 index 000000000..c15cf5b6e --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/MineMap.kt @@ -0,0 +1,18 @@ +package minesweeper.domain + +class MineMap( + private val _values: MutableMap = mutableMapOf() +) { + val values: Map + get() = _values + val size: Int + get() = _values.keys.size + + fun plantCell(position: Position, cell: Cell) { + _values[position] = cell + } + + fun getCell(position: Position): Cell { + return _values[position] ?: throw IllegalArgumentException("해당 위치에 셀이 없습니다") + } +} diff --git a/src/main/kotlin/minesweeper/domain/MineMapGenerator.kt b/src/main/kotlin/minesweeper/domain/MineMapGenerator.kt new file mode 100644 index 000000000..9175779f4 --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/MineMapGenerator.kt @@ -0,0 +1,10 @@ +package minesweeper.domain + +object MineMapGenerator { + fun generate(minePositions: Positions, emptyPositions: Positions): MineMap { + val mineMap = MineMap() + minePositions.forEach { mineMap.plantCell(it, Cell(CellState.MINE)) } + emptyPositions.forEach { mineMap.plantCell(it, Cell(CellState.EMPTY)) } + return mineMap + } +} diff --git a/src/main/kotlin/minesweeper/domain/MineMapMeta.kt b/src/main/kotlin/minesweeper/domain/MineMapMeta.kt new file mode 100644 index 000000000..ff159a25f --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/MineMapMeta.kt @@ -0,0 +1,18 @@ +package minesweeper.domain + +data class MineMapMeta( + val height: Int, + val width: Int, + val mineCount: Int +) { + init { + require(height > 0) { "높이는 0 이거나 음수일 수 없습니다" } + require(width > 0) { "너비는 0 이거나 음수일 수 없습니다" } + require(mineCount > 0) { "지뢰 개수는 0 이거나 음수일 수 없습니다" } + require(height * width >= mineCount) { "지뢰 개수는 (높이 x 너비) 개수를 초과할 수 없습니다" } + } + + fun getCellCount(): Int { + return height * width + } +} diff --git a/src/main/kotlin/minesweeper/domain/Position.kt b/src/main/kotlin/minesweeper/domain/Position.kt new file mode 100644 index 000000000..27ca26655 --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/Position.kt @@ -0,0 +1,11 @@ +package minesweeper.domain + +data class Position( + val y: Int, + val x: Int +) { + init { + require(y > 0) { "y는 0이거나 음수일 수 없습니다" } + require(x > 0) { "x는 0이거나 음수일 수 없습니다" } + } +} diff --git a/src/main/kotlin/minesweeper/domain/PositionGenerator.kt b/src/main/kotlin/minesweeper/domain/PositionGenerator.kt new file mode 100644 index 000000000..2f9a081fa --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/PositionGenerator.kt @@ -0,0 +1,35 @@ +package minesweeper.domain + +class PositionGenerator( + private val mineMapMeta: MineMapMeta, + private val positionSelector: PositionSelector = RandomPositionSelector +) { + tailrec fun generateMinePositions( + minePositions: Positions = Positions() + ): Positions { + if (minePositions.size == mineMapMeta.mineCount) { return minePositions } + val randomMinePosition = positionSelector.select(mineMapMeta) + return if (randomMinePosition in minePositions) { + generateMinePositions(minePositions) + } else { + generateMinePositions(minePositions + randomMinePosition) + } + } + + fun generateEmptyPositions( + minePositions: Positions + ): Positions { + val allPositions = generateAllPositions() + require(allPositions.size == mineMapMeta.getCellCount()) { "모든 위치를 생성하지 못했습니다" } + val emptyPositions = allPositions - minePositions + require(!emptyPositions.containSamePosition(minePositions)) { "지뢰와 빈 공간은 겹칠 수 없습니다." } + return emptyPositions + } + + private fun generateAllPositions(): Positions { + return (1..mineMapMeta.height) + .flatMap { y -> (1..mineMapMeta.width).map { x -> Position(y, x) } } + .toSet() + .toPositions() + } +} diff --git a/src/main/kotlin/minesweeper/domain/PositionSelector.kt b/src/main/kotlin/minesweeper/domain/PositionSelector.kt new file mode 100644 index 000000000..b180ddbf1 --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/PositionSelector.kt @@ -0,0 +1,5 @@ +package minesweeper.domain + +interface PositionSelector { + fun select(mineMapMeta: MineMapMeta): Position +} diff --git a/src/main/kotlin/minesweeper/domain/Positions.kt b/src/main/kotlin/minesweeper/domain/Positions.kt new file mode 100644 index 000000000..7901e5b8b --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/Positions.kt @@ -0,0 +1,14 @@ +package minesweeper.domain + +class Positions( + private val positions: Set = emptySet() +) : Set by positions { + infix fun containSamePosition(otherPositions: Positions): Boolean = positions.intersect(otherPositions).isNotEmpty() + + operator fun plus(position: Position): Positions = Positions(this.positions + position) + operator fun plus(positions: Positions): Positions = Positions(this.positions + positions.positions) + operator fun minus(position: Position): Positions = Positions(this.positions - position) + operator fun minus(positions: Positions): Positions = Positions(this.positions - positions.positions) +} + +fun Set.toPositions(): Positions = Positions(this) diff --git a/src/main/kotlin/minesweeper/domain/RandomPositionSelector.kt b/src/main/kotlin/minesweeper/domain/RandomPositionSelector.kt new file mode 100644 index 000000000..5350829cb --- /dev/null +++ b/src/main/kotlin/minesweeper/domain/RandomPositionSelector.kt @@ -0,0 +1,13 @@ +package minesweeper.domain + +import kotlin.random.Random + +object RandomPositionSelector : PositionSelector { + override fun select( + mineMapMeta: MineMapMeta + ): Position { + val x = Random.nextInt(1, mineMapMeta.width + 1) + val y = Random.nextInt(1, mineMapMeta.height + 1) + return Position(y, x) + } +} diff --git a/src/main/kotlin/minesweeper/view/InputView.kt b/src/main/kotlin/minesweeper/view/InputView.kt new file mode 100644 index 000000000..0cd0f9262 --- /dev/null +++ b/src/main/kotlin/minesweeper/view/InputView.kt @@ -0,0 +1,21 @@ +package minesweeper.view + +object InputView { + fun getHeight(): Int { + return getNumber("높이를 입력하세요.") + } + + fun getWidth(): Int { + return getNumber("너비를 입력하세요.") + } + + fun getMineCount(): Int { + return getNumber("지뢰는 몇 개인가요?") + } + + private fun getNumber(consoleMsg: String): Int { + println(consoleMsg) + val input = readlnOrNull() ?: throw IllegalArgumentException("입력값은 공백이거나 빈 문자열일 수 없습니다.") + return input.toIntOrNull() ?: throw IllegalArgumentException("숫자가 아닌 입력값은 들어올 수 없습니다.") + } +} diff --git a/src/main/kotlin/minesweeper/view/OutputView.kt b/src/main/kotlin/minesweeper/view/OutputView.kt new file mode 100644 index 000000000..8585b5724 --- /dev/null +++ b/src/main/kotlin/minesweeper/view/OutputView.kt @@ -0,0 +1,25 @@ +package minesweeper.view + +import minesweeper.domain.MineMap +import minesweeper.domain.MineMapMeta +import minesweeper.domain.Position +import minesweeper.domain.getStateSymbol + +object OutputView { + fun printGameStartMsg() { + println("\n지뢰 찾기 게임 시작") + } + + fun printMineMap(mineMapMeta: MineMapMeta, mineMap: MineMap) { + for (row in 1 until mineMapMeta.height + 1) { + printRowCells(mineMapMeta, mineMap, row) + } + } + + private fun printRowCells(mineMapMeta: MineMapMeta, mineMap: MineMap, row: Int) { + for (col in 1 until mineMapMeta.width + 1) { + print(mineMap.getCell(Position(row, col)).getStateSymbol() + " ") + } + println() + } +} diff --git a/src/test/kotlin/minesweeper/domain/MineMapGeneratorTest.kt b/src/test/kotlin/minesweeper/domain/MineMapGeneratorTest.kt new file mode 100644 index 000000000..254074821 --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/MineMapGeneratorTest.kt @@ -0,0 +1,31 @@ +package minesweeper.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MineMapGeneratorTest { + @Test + fun `지뢰 맵을 구성할 위치 정보가 주어졌을 때 MineMap을 생성할 수 있다`() { + // given + val mineMapMeta = MineMapMeta(3, 3, 3) + val minePositions = setOf( + Position(1, 1), + Position(2, 2), + Position(3, 3) + ).toPositions() + val emptyPositions = setOf( + Position(1, 2), + Position(1, 3), + Position(2, 1), + Position(2, 3), + Position(3, 1), + Position(3, 2) + ).toPositions() + + // when + val mineMap = MineMapGenerator.generate(minePositions, emptyPositions) + + // then + assertEquals(mineMapMeta.getCellCount(), mineMap.values.size) + } +} diff --git a/src/test/kotlin/minesweeper/domain/MineMapMetaTest.kt b/src/test/kotlin/minesweeper/domain/MineMapMetaTest.kt new file mode 100644 index 000000000..1661e2e3b --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/MineMapMetaTest.kt @@ -0,0 +1,69 @@ +package minesweeper.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class MineMapMetaTest { + @ParameterizedTest + @CsvSource( + "0 0 0", + "1 0 0", + "1 1 -1", + "0 -1 -1" + ) + fun `MineMapMeta 프로퍼티는 0이거나 음수일 수 없다`(input: String) { + // given + val properties = input.split(" ").map { it.toInt() } + + assertThrows { // then + MineMapMeta( // when + height = properties[0], + width = properties[1], + mineCount = properties[2] + ) + } + } + + @ParameterizedTest + @CsvSource( + "1 1 2", + "10 1 11", + "10 10 101" + ) + fun `지뢰 개수는 MineMap을 구성하는 셀 크기(height*width)를 초과할 수 없다`(input: String) { + // given + val properties = input.split(" ").map { it.toInt() } + + assertThrows { // then + MineMapMeta( // when + height = properties[0], + width = properties[1], + mineCount = properties[2] + ) + } + } + + @ParameterizedTest + @CsvSource( + "1 1 1, 1", + "1 10 5, 10", + "10 30 50, 300" + ) + fun `MineMap을 구성하는 Cell 개수를 반환한다`(input: String, expected: Int) { + // given + val properties = input.split(" ").map { it.toInt() } + val mineMapMeta = MineMapMeta( + height = properties[0], + width = properties[1], + mineCount = properties[2] + ) + + // when + val cellCount = mineMapMeta.getCellCount() + + // then + assertEquals(expected, cellCount) + } +} diff --git a/src/test/kotlin/minesweeper/domain/MineMapTest.kt b/src/test/kotlin/minesweeper/domain/MineMapTest.kt new file mode 100644 index 000000000..762a11aba --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/MineMapTest.kt @@ -0,0 +1,59 @@ +package minesweeper.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class MineMapTest { + @Test + fun `위치 정보를 전달해 MineMap에 지뢰를 심는다`() { + // given + val mineMap = MineMap() + + // when + mineMap.plantCell(Position(1, 1), Cell(CellState.MINE)) + + // then + assertEquals(1, mineMap.size) + assertEquals(CellState.MINE, mineMap.values[Position(1, 1)]?.state) + } + + @Test + fun `위치 정보를 전달해 MineMap에 빈 상태를 심는다`() { + // given + val mineMap = MineMap() + + // when + mineMap.plantCell(Position(1, 1), Cell(CellState.EMPTY)) + mineMap.plantCell(Position(2, 2), Cell(CellState.EMPTY)) + + // then + assertEquals(2, mineMap.size) + assertEquals(CellState.EMPTY, mineMap.values[Position(1, 1)]?.state) + assertEquals(CellState.EMPTY, mineMap.values[Position(2, 2)]?.state) + } + + @Test + fun `주어진 위치 정보에 놓인 Cell 객체를 가져온다`() { + // given + val mineMap = MineMap() + val position = Position(1, 1) + mineMap.plantCell(position, Cell(CellState.EMPTY)) + + // when + val cell = mineMap.getCell(position) + + // then + assertEquals(CellState.EMPTY, cell.state) + } + + @Test + fun `MineMap에 없는 위치 정보를 통해 Cell 객체를 가져온다면 IllegalArgumentException이 발생한다`() { + // given + val mineMap = MineMap() + + assertThrows { // then + mineMap.getCell(Position(1, 1)) // when + } + } +} diff --git a/src/test/kotlin/minesweeper/domain/PositionGeneratorTest.kt b/src/test/kotlin/minesweeper/domain/PositionGeneratorTest.kt new file mode 100644 index 000000000..f3cbbe4f6 --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/PositionGeneratorTest.kt @@ -0,0 +1,39 @@ +package minesweeper.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PositionGeneratorTest { + @Test + fun `지뢰 메타 정보(지뢰 수, 지뢰맵 크기)가 주어질 때 mineCount 만큼의 지뢰 위치 정보 Set을 생성하고 반환한다`() { + // given + val mineMapMeta = MineMapMeta(10, 10, 10) + val positionGenerator = PositionGenerator(mineMapMeta) + + // when + val minePositions = positionGenerator.generateMinePositions() + + // then + assertEquals(mineMapMeta.mineCount, minePositions.size) + assertEquals(true, minePositions.all { it.x in 1..10 && it.y in 1..10 }) + } + + @Test + fun `지뢰 메타 정보와 지뢰 위치 Set이 주어질 때 빈칸의 위치 정보 Set을 반환한다`() { + // given + val mineMapMeta = MineMapMeta(10, 10, 10) + val minePositions = setOf( + Position(1, 1), + Position(5, 5), + Position(10, 10) + ).toPositions() + val positionGenerator = PositionGenerator(mineMapMeta) + + // when + val emptyPositions = positionGenerator.generateEmptyPositions(minePositions) + + // then + assertEquals(mineMapMeta.getCellCount() - minePositions.size, emptyPositions.size) + assertEquals(true, emptyPositions.all { it.x in 1..10 && it.y in 1..10 }) + } +} diff --git a/src/test/kotlin/minesweeper/domain/PositionTest.kt b/src/test/kotlin/minesweeper/domain/PositionTest.kt new file mode 100644 index 000000000..a43819cc3 --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/PositionTest.kt @@ -0,0 +1,23 @@ +package minesweeper.domain + +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class PositionTest { + @ParameterizedTest + @CsvSource( + "0 0", + "1 0", + "0 1", + "-1 -1" + ) + fun `Position 프로퍼티가 0이거나 음수라면 IllegalArgumentException이 발생한다`(input: String) { + // given + val properties = input.split(" ").map { it.toInt() } + + assertThrows { // then + Position(properties[0], properties[1]) // when + } + } +} diff --git a/src/test/kotlin/minesweeper/domain/PositionsTest.kt b/src/test/kotlin/minesweeper/domain/PositionsTest.kt new file mode 100644 index 000000000..d0a2b9d0f --- /dev/null +++ b/src/test/kotlin/minesweeper/domain/PositionsTest.kt @@ -0,0 +1,146 @@ +package minesweeper.domain + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PositionsTest { + private val positions = setOf( + Position(1, 1), + Position(2, 2), + Position(3, 3) + ).toPositions() + + @Test + fun `두 Positions 중 중복 Position 포함 여부를 알 수 있다`() { + // given + val anotherPosition = setOf( + Position(1, 1), + Position(20, 20), + Position(30, 30) + ).toPositions() + + // when + val isContainSamePosition = positions containSamePosition anotherPosition + + // then + assertEquals(true, isContainSamePosition) + } + + @Test + fun `Positions이 주어질 때 1개 Position을 더할 수 있다`() { + // given + val position = Position(100, 100) + + // when + val newPositions = positions + position + + // then + assertEquals(4, newPositions.size) + assertThat(newPositions == setOf( + Position(1, 1), + Position(2, 2), + Position(3, 3), + Position(100, 100) + ).toPositions()) + } + + @Test + fun `Positions에 Position을 더할 때 중복은 제거된다`() { + // given + val position = Position(1, 1) + + // when + val newPositions = positions + position + + // then + assertEquals(3, newPositions.size) + assertThat(newPositions == setOf( + Position(1, 1), + Position(2, 2), + Position(3, 3) + ).toPositions()) + } + + @Test + fun `Positions이 주어질 때 다른 Positions를 더할 수 있다`() { + // given + val anotherPositions = setOf( + Position(100, 100), + Position(200, 200), + Position(300, 300) + ).toPositions() + + // when + val newPositions = positions + anotherPositions + + // then + assertEquals(6, newPositions.size) + assertThat(newPositions == setOf( + Position(1, 1), + Position(2, 2), + Position(3, 3), + Position(100, 100), + Position(200, 200), + Position(300, 300) + ).toPositions()) + } + + @Test + fun `Positions에 Positions을 더할 때 중복은 제거된다`() { + // given + val anotherPositions = setOf( + Position(1, 1), + Position(20, 20), + Position(30, 30) + ).toPositions() + + // when + val newPositions = positions + anotherPositions + + // then + assertEquals(5, newPositions.size) + assertThat(newPositions == setOf( + Position(1, 1), + Position(2, 2), + Position(3, 3), + Position(20, 20), + Position(30, 30) + ).toPositions()) + } + + @Test + fun `Positions이 주어질 때 1개 Position를 뺄 수 있다`() { + // given + val position = Position(1, 1) + + // when + val newPositions = positions - position + + // then + assertEquals(2, newPositions.size) + assertThat(newPositions == setOf( + Position(2, 2), + Position(3, 3) + ).toPositions()) + } + + @Test + fun `Positions이 주어질 때 다른 Positions를 뺄 수 있다`() { + // given + val anotherPositions = setOf( + Position(1, 1), + Position(2, 2), + Position(30, 30) + ).toPositions() + + // when + val newPositions = positions - anotherPositions + + // then + assertEquals(1, newPositions.size) + assertThat(newPositions == setOf( + Position(3, 3) + ).toPositions()) + } +}