diff --git a/Dockerfile b/Dockerfile index 74547d7..b19a075 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM openjdk:17 +FROM eclipse-temurin:17-jdk + ENV TG_TARGET_ID=default ENV TG_MINE=default ENV WEATHER_KEY=default diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt index 4847417..7b6cd8f 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/BumsInterceptor.kt @@ -36,7 +36,7 @@ class BumsInterceptor( // if (!skippResourcesExtension) { // println("===============================================") // println("==================== BEGIN ====================") -// println("Request URL ===> " + request.requestURL) + println("Request URL ===> " + request.requestURL) // } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/RequestLoggingFilter.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/RequestLoggingFilter.kt new file mode 100644 index 0000000..0afcb6d --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/RequestLoggingFilter.kt @@ -0,0 +1,55 @@ +package kr.lunaticbum.back.lun.configs + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) // ๐Ÿ‘ˆ 1. ๋ชจ๋“  ํ•„ํ„ฐ ์ค‘ ๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰๋˜๋„๋ก ์„ค์ • +class RequestLoggingFilter : OncePerRequestFilter() { + + // 2. Slf4j ๋กœ๊ฑฐ ์ƒ์„ฑ (SecurityConfig์˜ LogService ๋Œ€์‹  ํ‘œ์ค€ ๋กœ๊ฑฐ ์‚ฌ์šฉ) + private val log = LoggerFactory.getLogger(RequestLoggingFilter::class.java) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val requestUri = request.requestURI + + // 3. (์„ ํƒ์ ) 'puzzle' ๊ด€๋ จ ์š”์ฒญ๋งŒ ๋กœ๊น…ํ•˜์—ฌ ๋กœ๊ทธ ์–‘ ์กฐ์ ˆ + val shouldLog = requestUri.contains("puzzle") || requestUri.contains("api") + + if (shouldLog) { + val method = request.method + val queryString = if (request.queryString != null) "?${request.queryString}" else "" + log.info(">>> REQUEST: [$method] $requestUri$queryString") + } + + try { + // 4. ์‹ค์ œ ์š”์ฒญ ์ฒ˜๋ฆฌ (๋‹ค์Œ ํ•„ํ„ฐ ๋˜๋Š” ์ปจํŠธ๋กค๋Ÿฌ๋กœ ์ „๋‹ฌ) + filterChain.doFilter(request, response) + } finally { + // 5. ์‘๋‹ต์ด ๋‚˜๊ฐˆ ๋•Œ ์ƒํƒœ ์ฝ”๋“œ ๋กœ๊น… + val status = response.status + + // โš ๏ธ 301 ๋ฆฌ๋””๋ ‰์…˜์ด ๋ฐœ์ƒํ•˜๋ฉด ๊ฒฝ๊ณ (WARN) ๋ ˆ๋ฒจ๋กœ ์ƒ์„ธํžˆ ๋กœ๊น… + if (status == HttpServletResponse.SC_MOVED_PERMANENTLY) { // 301 + val location = response.getHeader("Location") // ๋ฆฌ๋””๋ ‰์…˜ ๋Œ€์ƒ URL + log.warn("<<< RESPONSE 301 (Moved Permanently):") + log.warn("<<< FROM: [${request.method}] ${request.requestURI}") + log.warn("<<< TO: $location") // ๐Ÿ‘ˆ ์ด ์ฃผ์†Œ๋ฅผ ํ™•์ธํ•˜์„ธ์š”! + } + // (์„ ํƒ์ ) ๊ทธ ์™ธ 'puzzle' ์š”์ฒญ์˜ ์‘๋‹ต ๋กœ๊น… + else if (shouldLog) { + log.info("<<< RESPONSE: $status FOR [${request.method}] $requestUri") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index e82b9f5..7c4637f 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -159,6 +159,7 @@ class SecurityConfig( ) }.authorizeHttpRequests { auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 1. ์ •์  ๋ฆฌ์†Œ์Šค = permitAll .requestMatchers( "/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**" diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt index 67a1d99..167002b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -86,7 +86,13 @@ class PuzzleController( * ์Šค๋„์ฟ : ์ƒˆ ๊ฒŒ์ž„ ์‹œ์ž‘ (๋‚œ์ด๋„๋ณ„ ๋ฌธ์ œ ๋ฐ˜ํ™˜) */ @GetMapping("/sudoku/start") - suspend fun sudokuStartGame(@RequestParam(defaultValue = "easy") difficulty: String): PuzzleService.SudokuGameDto { + suspend fun sudokuStartGame( + // ๐Ÿ”ฝ [์ˆ˜์ •] 'level' -> 'difficulty', ๊ธฐ๋ณธ๊ฐ’ '4' + @RequestParam(defaultValue = "4") difficulty: String + // โŒ 'blockSizeStr' ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ + ): PuzzleService.SudokuGameDto { + + // ๐Ÿ”ฝ [์ˆ˜์ •] ํŒŒ๋ผ๋ฏธํ„ฐ 1๊ฐœ๋งŒ ์ „๋‹ฌ return puzzleService.sudoku_startGame(difficulty) } @@ -102,11 +108,28 @@ class PuzzleController( /** * ์Šค๋„์ฟ : (๊ด€๋ฆฌ์šฉ) ์ƒˆ ํผ์ฆ ๋ฌธ์ œ ์ƒ์„ฑ ๋ฐ DB ์ €์žฅ */ - @GetMapping("/sudoku/sudoku_gen") - suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle { - return puzzleService.sudoku_generateAndSavePuzzle() - } +// @GetMapping("/sudoku/sudoku_gen") +// suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle { +// puzzleService.sudoku_generateAndSavePuzzle() +// return SudokuPuzzle() +// } + @GetMapping("/admin/generate-puzzles") + suspend fun generateAdminPuzzles( + @RequestParam(defaultValue = "4") blockSize: Int, + @RequestParam(defaultValue = "5") count: Int + ): String { + // ์˜ˆ: /admin/generate-puzzles?blockSize=4&count=10 + // 16x16 ํผ์ฆ 10๊ฐœ ์ƒ์„ฑ + repeat(count) { + try { + puzzleService.sudoku_generateAndSavePuzzle(blockSize) + } catch (e: Exception) { + // (ํผ์ฆ ๋ฌธ์ž์—ด์ด unique=true์ด๋ฏ€๋กœ ์ค‘๋ณต๋˜๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ ๊ฐ€๋Šฅ) + } + } + return "$count ๊ฐœ์˜ $blockSize x $blockSize ํผ์ฆ ์ƒ์„ฑ ์™„๋ฃŒ." + } // ====================================================== // 3. SPIDER API (โ˜… SpiderController์—์„œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฐ Coroutine ๋ณ€ํ™˜๋จ) @@ -256,7 +279,7 @@ class GameRankController(private val gameRankService: GameRankService) { } .onErrorResume { // ๊ธฐํƒ€ ์˜ˆ์™ธ๋Š” 500 Internal Server Error๋กœ ์ฒ˜๋ฆฌ - Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("๋žญํ‚น ๋“ฑ๋ก ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")) + Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(it.message)) } } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt index 89d7de6..c7d4f14 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withContext import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite +import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.SudokuGenerator import org.springframework.beans.factory.annotation.Value import org.springframework.data.annotation.Id @@ -27,13 +28,16 @@ import kotlin.random.Random import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.mongodb.core.index.Indexed import org.springframework.data.repository.reactive.ReactiveSortingRepository +import org.springframework.scheduling.annotation.Scheduled import org.springframework.security.authentication.AnonymousAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import java.io.File import java.time.Instant import java.util.UUID - +import java.util.concurrent.ConcurrentHashMap +import org.springframework.security.core.userdetails.UsernameNotFoundException // ๐Ÿ‘ˆ [์ถ”๊ฐ€] +import reactor.core.scheduler.Schedulers // ๐Ÿ‘ˆ [์ถ”๊ฐ€] /** * ====================================================== @@ -80,7 +84,8 @@ class PuzzleService( // 3. Spider ์˜์กด์„ฑ private val spiderGameRepository: SpiderGameRepository, - @Value("\${puzzle.image.path}") private val puzzleImagePath: String + @Value("\${puzzle.image.path}") private val puzzleImagePath: String, + private val logService: LogService ) { companion object { @@ -254,39 +259,124 @@ class PuzzleService( // 2. SUDOKU ์„œ๋น„์Šค ๋กœ์ง (ํ†ตํ•ฉ๋จ) // ====================================================== - data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String) + // ๐Ÿ”ฝ [์ˆ˜์ •] DTO๊ฐ€ puzzleId๋ฅผ ๋‹ค์‹œ ํฌํ•จํ•˜๋„๋ก ๋ณ€๊ฒฝ + data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String, val blockSize: Int) data class SudokuValidateDto(val puzzleId: Long, val answer: String) + // [์‹ ๊ทœ] ์ƒ์„ฑ ์ž‘์—… ์ค‘๋ณต ์‹คํ–‰ ๋ฐฉ์ง€์šฉ ์ž ๊ธˆ + private val generationLocks = ConcurrentHashMap() + + /** + * [ํ•ต์‹ฌ ์ˆ˜์ •] + * - ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ 'difficulty' 1๊ฐœ๋งŒ ๋ฐ›์Šต๋‹ˆ๋‹ค. + * - 'difficulty' (1~8)๋ฅผ (blockSize, generatorLevel)๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ suspend fun sudoku_startGame(difficulty: String): SudokuGameDto { - val puzzleCount = sudokuPuzzleRepository.count() - if (puzzleCount == 0L) throw IllegalStateException("ํผ์ฆ์ด DB์— ์—†์Šต๋‹ˆ๋‹ค.") - val randomKey = Random.nextLong(1, puzzleCount - 1) - val solvedPuzzle = sudokuPuzzleRepository.findByPuzzleKey(randomKey) - ?: throw IllegalStateException("$randomKey ๋ฒˆ ํผ์ฆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") - - val holes = when (difficulty.lowercase()) { - "medium" -> 45 - "hard" -> 55 - else -> 35 // easy + // ๐Ÿ”ฝ [์‹ ๊ทœ] 8๋‹จ๊ณ„ ๋‚œ์ด๋„๋ฅผ (blockSize, generatorLevel)๋กœ ๋งคํ•‘ + val (blockSize, generatorLevel) = when (difficulty) { + "1" -> Pair(2, 1) // Level 1: 4x4, Easy + "2" -> Pair(2, 3) // Level 2: 4x4, Medium + "3" -> Pair(3, 1) // Level 3: 9x9, Easy + "4" -> Pair(3, 3) // Level 4: 9x9, Medium (Default) + "5" -> Pair(3, 5) // Level 5: 9x9, Hard + "6" -> Pair(4, 1) // Level 6: 16x16, Easy + "7" -> Pair(4, 3) // Level 7: 16x16, Medium + "8" -> Pair(4, 5) // Level 8: 16x16, Hard + else -> Pair(3, 3) // ๊ธฐ๋ณธ๊ฐ’ (Level 4) } - val question = sudoku_createQuestion(solvedPuzzle.puzzle!!, holes) - return SudokuGameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!) + // 1. DB์—์„œ ์กฐ๊ฑด์— ๋งž๋Š” '๋ฏธ์‚ฌ์šฉ ๋ฌธ์ œ' ๊ฒ€์ƒ‰ + val solutionPuzzle = sudokuPuzzleRepository + .findFirstByBlockSizeAndLevelAndPlayCountOrderByPuzzleKey(blockSize, generatorLevel, 0L) + // 2. ์—†์œผ๋ฉด ์˜ค๋ฅ˜ ๋ฐ˜ํ™˜ (์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ฑ„์›Œ์•ผ ํ•จ) + ?: throw IllegalStateException("ํ˜„์žฌ $blockSize x $blockSize (Level $generatorLevel) ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ƒˆ ํผ์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค.") + + // 3. ์ƒ์„ฑ๊ธฐ ์ธ์Šคํ„ด์Šคํ™” + val generator = SudokuGenerator(blockSize) + + // 4. DB์˜ ์ •๋‹ต(Solution)์œผ๋กœ ๋ฌธ์ œ(Question) ์ƒ์„ฑ + val puzzle = generator.generatePuzzleFromSolution(solutionPuzzle.solution!!, generatorLevel) + + // 5. playCount 1 ์ฆ๊ฐ€ ๋ฐ ์ €์žฅ + sudokuPuzzleRepository.save( + solutionPuzzle.copy(playCount = solutionPuzzle.playCount + 1) + ) + + // 6. DTO๋กœ ๋ฐ˜ํ™˜ + return SudokuGameDto( + puzzleId = solutionPuzzle.puzzleKey ?: 0L, + question = puzzle.question!!, + solution = puzzle.solution!!, + blockSize = solutionPuzzle.blockSize + ) } + @Scheduled(fixedRate = 600000, initialDelay = 1000) + suspend fun fillPuzzleCache() { + // ๐Ÿ”ฝ [์ˆ˜์ •] 2..5 -> 2..4 (4x4๊นŒ์ง€๋งŒ) + for (blockSize in 2..4) { + for (level in 1..5) { // 1~5 ๋‚œ์ด๋„ + // (์ดํ•˜ ๋กœ์ง์€ ๊ธฐ์กด๊ณผ ๋™์ผ) + val lockKey = "$blockSize-$level" + if (generationLocks.putIfAbsent(lockKey, true) != null) { + continue + } + try { + val unplayedCount = sudokuPuzzleRepository + .countByBlockSizeAndLevelAndPlayCount(blockSize, level, 0L) + + val minCacheSize = 5 + if (unplayedCount < minCacheSize) { + logService.log("[$blockSize x $blockSize Level $level] ๋ฏธ์‚ฌ์šฉ ํผ์ฆ ${unplayedCount}๊ฐœ ๊ฐ์ง€. ${minCacheSize}๊ฐœ๊นŒ์ง€ ์ƒ์„ฑ ์‹œ์ž‘...") + repeat((minCacheSize - unplayedCount).toInt()) { + sudoku_generateAndSavePuzzle(blockSize, level) + } + logService.log("[$blockSize x $blockSize Level $level] ํผ์ฆ ์ƒ์„ฑ ์™„๋ฃŒ.") + } + } catch (e: Exception) { + logService.log("[$blockSize x $blockSize Level $level] ํผ์ฆ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜: ${e.message}") + } finally { + generationLocks.remove(lockKey) + } + } + } + } + + /** + * [์ˆ˜์ •] Generator์˜ ๋กœ์ปฌ ๊ฒ€์ฆ ๋Œ€์‹ , DB์˜ ์ •๋‹ต๊ณผ ์ง์ ‘ ๋น„๊ต (๊ฐ€์žฅ ํ™•์‹คํ•œ ๋ฐฉ์‹) + */ suspend fun sudoku_validateSolution(validateDto: SudokuValidateDto): Boolean { + // 1. [์ˆ˜์ •] DB์—์„œ 'puzzleId'๋กœ ์ •๋‹ต์„ ์ฐพ์Œ val originalPuzzle = sudokuPuzzleRepository.findByPuzzleKey(validateDto.puzzleId) ?: throw IllegalStateException("ํผ์ฆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") - return originalPuzzle.puzzle == validateDto.answer + + // 2. [์ˆ˜์ •] DB์˜ ์ •๋‹ต๊ณผ ๋ฌธ์ž์—ด ๋น„๊ต + return originalPuzzle.solution == validateDto.answer } - suspend fun sudoku_generateAndSavePuzzle(): SudokuPuzzle { - val puzzleString = SudokuGenerator().generate() + /** + * [์ˆ˜์ •] ์ด ํ•จ์ˆ˜๋Š” ์ด์ œ '์ƒˆ ์ •๋‹ต ํผ์ฆ'์„ 1๊ฐœ ์ƒ์„ฑํ•˜์—ฌ + * 'playCount = 0' ์ƒํƒœ๋กœ DB์— ์ €์žฅํ•˜๊ณ , '์ €์žฅ๋œ ๊ฐ์ฒด'๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ + suspend fun sudoku_generateAndSavePuzzle(blockSize: Int = 3, level: Int = 3) { // ๐Ÿ‘ˆ [์ˆ˜์ •] + val generator = SudokuGenerator(blockSize) + + // [์ˆ˜์ •] generatePuzzle์€ Solution๊ณผ Question์„ ๋ชจ๋‘ ๋ฐ˜ํ™˜ + val newPuzzle = generator.generatePuzzle(level) + val lastPuzzle = sudokuPuzzleRepository.findTopByOrderByPuzzleKeyDesc() val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L - val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString) - return sudokuPuzzleRepository.save(newPuzzle) + + val puzzleToSave = SudokuPuzzle( + puzzleKey = nextKey, + solution = newPuzzle.solution, // ๐Ÿ‘ˆ ์ •๋‹ต ์ €์žฅ + question = newPuzzle.question, // ๐Ÿ‘ˆ ๋ฌธ์ œ ์ €์žฅ + blockSize = blockSize, + level = level, // ๐Ÿ‘ˆ ๋ ˆ๋ฒจ ์ €์žฅ + playCount = 0L + ) + sudokuPuzzleRepository.save(puzzleToSave) } private fun sudoku_createQuestion(puzzle: String, holes: Int): String { @@ -488,13 +578,19 @@ interface SpiderGameRepository : ReactiveMongoRepository { /** * ์Šค๋„์ฟ  ํผ์ฆ ์›๋ณธ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๋Š” ๋ชจ๋ธ */ -@Document(collection = "Sudoku") // (์ฐธ๊ณ : ๋…ธ๋…ธ๊ทธ๋žจ๊ณผ ๊ฐ™์€ ์ปฌ๋ ‰์…˜์„ ์“ฐ์ง€๋งŒ ๊ตฌ์กฐ๊ฐ€ ๋‹ค๋ฆ„) +@Document(collection = "Sudoku") data class SudokuPuzzle( @Id val id: String? = null, val puzzleKey: Long? = null, - @Indexed(unique = true) - val puzzle: String? // 81์ž๋ฆฌ ์™„์„ฑ๋œ ํผ์ฆ ๋ฐ์ดํ„ฐ + + // ๐Ÿ”ฝ [์ˆ˜์ •] 'solution' (์ •๋‹ต)๊ณผ 'question' (๋ฌธ์ œ)์„ ๋ชจ๋‘ ์ €์žฅ + val solution: String?, // ์™„์„ฑ๋œ ํผ์ฆ + val question: String?, // ๋นˆ์นธ์ด ๋šซ๋ฆฐ ํผ์ฆ + + val blockSize: Int = 3, + val level: Int = 3, // ๐Ÿ”ฝ [์ถ”๊ฐ€] ์ด ํผ์ฆ์˜ ๋‚œ์ด๋„ (1~5) + val playCount: Long = 0L ) /** @@ -502,34 +598,49 @@ data class SudokuPuzzle( */ @Repository interface SudokuPuzzleRepository : CoroutineCrudRepository { + // ๐Ÿ”ฝ [์ˆ˜์ •] override suspend fun count(): Long -> countByBlockSize + suspend fun countByBlockSize(blockSize: Int): Long // ํŠน์ • ํฌ๊ธฐ์˜ ํผ์ฆ๋งŒ ์นด์šดํŠธ override suspend fun count(): Long suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? + // ๐Ÿ”ฝ [์‹ ๊ทœ] ํŠน์ • blockSize์˜ ํผ์ฆ ์ค‘ ๋žœ๋ค 1๊ฐœ ์กฐํšŒ + @Aggregation(pipeline = [ + "{ \$match: { blockSize: ?0 } }", // ?0์€ ์ฒซ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ(blockSize) + "{ \$sample: { size: 1 } }" + ]) + suspend fun findRandomByBlockSize(blockSize: Int): SudokuPuzzle? + + // ๐Ÿ”ฝ [์ˆ˜์ •] blockSize, level, playCount ๋ชจ๋‘ ์ผ์น˜ํ•˜๋Š” ํผ์ฆ ๊ฒ€์ƒ‰ + suspend fun findFirstByBlockSizeAndLevelAndPlayCountOrderByPuzzleKey( + blockSize: Int, + level: Int, + playCount: Long + ): SudokuPuzzle? + + // ๐Ÿ”ฝ [์ถ”๊ฐ€] ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์‚ฌ์šฉํ• , ํŠน์ • ์กฐ๊ฑด์˜ ํผ์ฆ ๊ฐœ์ˆ˜ ์นด์šดํŠธ + suspend fun countByBlockSizeAndLevelAndPlayCount( + blockSize: Int, + level: Int, + playCount: Long + ): Long + + } - -/** - * ๋ชจ๋“  ๊ฒŒ์ž„์˜ ๋žญํ‚น์„ ์ €์žฅํ•˜๋Š” ํ†ตํ•ฉ ๋ชจ๋ธ - */ +// In PuzzleData.kt @Document(collection = "game_ranks") data class GameRank( @Id val id: String? = null, - val gameType: GameType, // ๊ฒŒ์ž„ ์ข…๋ฅ˜ (2048, SUDOKU, SPIDER ๋“ฑ) - val contextId: String?, // ๊ฒŒ์ž„์˜ ์„ธ๋ถ€ ID (์˜ˆ: ์Šค๋„์ฟ  ํผ์ฆ Key, ์ŠคํŒŒ์ด๋” ๋‚œ์ด๋„, ๋…ธ๋…ธ๊ทธ๋žจ ํผ์ฆ ID) - val playerName: String, // ํ‘œ์ค€ํ™”๋œ ํ”Œ๋ ˆ์ด์–ด ์ด๋ฆ„ ํ•„๋“œ - /** * ๊ธฐ๋ณธ ์ ์ˆ˜ ํ•„๋“œ (์ •๋ ฌ 1์ˆœ์œ„). - * - 2048: ์ ์ˆ˜ (๋†’์„์ˆ˜๋ก ์ข‹์Œ) - * - Sudoku: ์™„๋ฃŒ ์‹œ๊ฐ„(์ดˆ) (๋‚ฎ์„์ˆ˜๋ก ์ข‹์Œ) - * - Spider: ์ด๋™ ํšŸ์ˆ˜ (๋‚ฎ์„์ˆ˜๋ก ์ข‹์Œ) - */ + @Indexed // ๐Ÿ‘ˆ [์ถ”๊ฐ€] + val userId: String?, // ๐Ÿ‘ˆ [์ถ”๊ฐ€] ์•ฑ-๊ณ ์œ  ID ๋˜๋Š” ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ID + + val gameType: GameType, + val contextId: String?, + val playerName: String, val primaryScore: Long, - - /** * ๋ณด์กฐ ์ ์ˆ˜ ํ•„๋“œ (์ •๋ ฌ 2์ˆœ์œ„. ์˜ˆ: ์ŠคํŒŒ์ด๋”์˜ ์™„๋ฃŒ ์‹œ๊ฐ„). - */ val secondaryScore: Long? = null, - val timestamp: Instant = Instant.now() ) @@ -547,6 +658,7 @@ enum class GameType { * ๋žญํ‚น ๋“ฑ๋ก ์‹œ ๋ชจ๋“  ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•  DTO */ data class UnifiedRankDto( + val userId: String, // ๐Ÿ‘ˆ [์ถ”๊ฐ€] (๋„ ํ—ˆ์šฉ ์•ˆ ํ•จ) val gameType: GameType, val contextId: String?, val playerName: String, @@ -572,15 +684,22 @@ interface GameRankRepository : ReactiveSortingRepository { // [์‹ ๊ทœ ์ถ”๊ฐ€] ํŠน์ • ํ”Œ๋ ˆ์ด์–ด์˜ ๋žญํ‚น์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒ fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux + + // ๐Ÿ”ฝ [์ถ”๊ฐ€] + fun findFirstByUserId(userId: String): Mono + fun findByPlayerName(playerName: String): Flux // ์ด๋ฆ„ ์ค‘๋ณต ํ™•์ธ์šฉ + } @Service class GameRankService( private val rankRepository: GameRankRepository, - private val userManager: UserManager ) { + private val userManager: UserManager, // ๐Ÿ‘ˆ ์ด ์ปดํฌ๋„ŒํŠธ๊ฐ€ Blocking IO๋ฅผ ์œ ๋ฐœ + private val logService: LogService +) { /** - * ๊ฒŒ์ž„ ํƒ€์ž…์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ์ •๋ ฌ ๋ฐฉ์‹์œผ๋กœ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + * ๊ฒŒ์ž„ ํƒ€์ž…์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ์ •๋ ฌ ๋ฐฉ์‹์œผ๋กœ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (๋ณ€๊ฒฝ ์—†์Œ) */ fun getRanks(gameType: GameType, contextId: String?): Flux { return when (gameType) { @@ -595,50 +714,99 @@ class GameRankService( } /** - * [์ˆ˜์ •] ๊ณตํ†ต DTO๋ฅผ ๋ฐ›์•„ ๋žญํ‚น์„ ์ €์žฅ (์‚ฌ์šฉ์ž ์ด๋ฆ„ ์ค‘๋ณต ์ฒดํฌ ๋กœ์ง ์ถ”๊ฐ€) + * [์ˆ˜์ •] ๊ณตํ†ต DTO๋ฅผ ๋ฐ›์•„ ๋žญํ‚น์„ ์ €์žฅ (Blocking IO ๋ฐ ๋ชจ๋“  ์˜ˆ์™ธ ์ฒ˜๋ฆฌ) */ fun submitRank(rankDto: UnifiedRankDto): Mono { val auth = SecurityContextHolder.getContext().authentication - - // ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž์ธ์ง€, ๋น„๋กœ๊ทธ์ธ(์ต๋ช…) ์‚ฌ์šฉ์ž์ธ์ง€ ํ™•์ธ val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken - if (isAuthenticated) { - // ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž: DTO์˜ playerName์„ ์‹ค์ œ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ID๋กœ ๊ฐ•์ œ ์„ค์ • (๋ณด์•ˆ ๊ฐ•ํ™”) - val principal = auth.principal as UserDetails - val authenticatedUsername = principal.username + val gameRankMono: Mono - val gameRank = GameRank( + if (isAuthenticated) { + // --- 1. ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž (๋กœ๊ทธ์ธ ์ƒํƒœ) --- + val principal = auth.principal as UserDetails + val authenticatedUserId = principal.username + gameRankMono = Mono.just(GameRank( + userId = authenticatedUserId, gameType = rankDto.gameType, contextId = rankDto.contextId, - playerName = authenticatedUsername, // ์‹ค์ œ ์ธ์ฆ๋œ ์ด๋ฆ„ ์‚ฌ์šฉ + playerName = authenticatedUserId, primaryScore = rankDto.primaryScore, secondaryScore = rankDto.secondaryScore - ) - return rankRepository.save(gameRank) + )) + } else { - // ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž: ์ž…๋ ฅํ•œ ์ด๋ฆ„์ด ๊ธฐ์กด ํšŒ์› ID์™€ ์ค‘๋ณต๋˜๋Š”์ง€ ํ™•์ธ - return userManager.findById(rankDto.playerName) - .flatMap { existingUser -> - // ์‚ฌ์šฉ์ž๊ฐ€ ์กด์žฌํ•˜๋ฉด ์—๋Ÿฌ ๋ฐœ์ƒ - Mono.error(IllegalArgumentException("์ด๋ฏธ ๋“ฑ๋ก๋œ ํšŒ์›์˜ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ด๋ฆ„์„ ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”.")) + // --- 2. ์ต๋ช… ์‚ฌ์šฉ์ž (๋น„๋กœ๊ทธ์ธ ์ƒํƒœ) --- + val anonymousUserId = rankDto.userId + val requestedName = rankDto.playerName + + // ๐Ÿ”ฝ [์ˆ˜์ •] 'checkAuthUsers'์˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ๋” ๊ฒฌ๊ณ ํ•˜๊ฒŒ ๋ณ€๊ฒฝ + val checkAuthUsers = Mono.fromCallable { + userManager.loadUserByUsername(requestedName) + } + .subscribeOn(Schedulers.boundedElastic()) // ๋ธ”๋กœํ‚น IO ์ž‘์—…์„ ์œ„ํ•œ ์Šค์ผ€์ค„๋Ÿฌ + .flatMap { + // loadUserByUsername์ด UserDetails๋ฅผ ๋ฐ˜ํ™˜ํ–ˆ๋‹ค๋ฉด (์˜ˆ์™ธ๊ฐ€ ์•ˆ ๋‚ฌ๋‹ค๋ฉด) + // -> ์ด๋ฆ„์ด ์ค‘๋ณต๋œ๋‹ค๋Š” ๋œป + Mono.error(IllegalArgumentException("์ด๋ฏธ ๋“ฑ๋ก๋œ ํšŒ์›์˜ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค.")) + } + .onErrorResume { error -> + // [์ˆ˜์ •] ๋ชจ๋“  ์˜ˆ์™ธ(error)๋ฅผ ์žก์•„์„œ ์ฒ˜๋ฆฌ + when (error) { + is UsernameNotFoundException -> { + // '์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ'์€ ์ •์ƒ์ ์ธ '์ค‘๋ณต ์•„๋‹˜' ์‹œ๋‚˜๋ฆฌ์˜ค์ž„ + Mono.empty() + } + is IllegalArgumentException -> { + // 'flatMap'์—์„œ ์šฐ๋ฆฌ๊ฐ€ ์ง์ ‘ ๋ฐœ์ƒ์‹œํ‚จ "์ด๋ฏธ ๋“ฑ๋ก๋œ ํšŒ์›" ์˜ค๋ฅ˜ + Mono.error(error) + } + else -> { + // ๊ทธ ์™ธ์˜ ๋ชจ๋“  ์„œ๋ฒ„ ๋‚ด๋ถ€ ํฌ๋ž˜์‹œ (NPE, DB ์˜ค๋ฅ˜ ๋“ฑ) + logService.log("!!! submitRank: checkAuthUsers ์ค‘ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ํฌ๋ž˜์‹œ ๋ฐœ์ƒ !!!", error) + Mono.error(IllegalArgumentException("์ด๋ฆ„ ํ™•์ธ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")) + } + } + } + + // 2b. 'rankRepository'๋Š” 'Reactive'์ด๋ฏ€๋กœ Schedulers๊ฐ€ ํ•„์š” ์—†์Œ (๊ธฐ์กด๊ณผ ๋™์ผ) + val checkAnonymousUsers = rankRepository.findFirstByUserId(anonymousUserId) + .flatMap { existingRank -> + if (existingRank.playerName != requestedName) { + Mono.error(IllegalArgumentException("ํ•ด๋‹น ID๋Š” ์ด๋ฏธ '${existingRank.playerName}' ๋‹˜์œผ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.")) + } else { + Mono.empty() + } } .switchIfEmpty(Mono.defer { - // ์‚ฌ์šฉ์ž๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ๋žญํ‚น ์ €์žฅ ์ง„ํ–‰ - val gameRank = GameRank( - gameType = rankDto.gameType, - contextId = rankDto.contextId, - playerName = rankDto.playerName, - primaryScore = rankDto.primaryScore, - secondaryScore = rankDto.secondaryScore - ) - rankRepository.save(gameRank) + rankRepository.findByPlayerName(requestedName).hasElements() + .flatMap { nameExists -> + if (nameExists) { + Mono.error(IllegalArgumentException("์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค.")) + } else { + Mono.empty() + } + } }) + + // 2c. ๋ชจ๋“  ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•˜๋ฉด ๋žญํ‚น ์ƒ์„ฑ (๊ธฐ์กด๊ณผ ๋™์ผ) + gameRankMono = checkAuthUsers.then(checkAnonymousUsers) + .then(Mono.just(GameRank( + userId = anonymousUserId, + gameType = rankDto.gameType, + contextId = rankDto.contextId, + playerName = requestedName, + primaryScore = rankDto.primaryScore, + secondaryScore = rankDto.secondaryScore + ))) } + + // 3. ๋žญํ‚น ์ €์žฅ + return gameRankMono.flatMap { rankRepository.save(it) } } /** - * ํŠน์ • ํ”Œ๋ ˆ์ด์–ด์˜ ๋ชจ๋“  ๊ฒŒ์ž„ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + * ํŠน์ • ํ”Œ๋ ˆ์ด์–ด์˜ ๋ชจ๋“  ๊ฒŒ์ž„ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (๋ณ€๊ฒฝ ์—†์Œ) */ fun getRanksByPlayer(playerName: String): Flux { return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/Logger.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/Logger.kt index 6c3a4bc..3617953 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/utils/Logger.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/Logger.kt @@ -52,6 +52,9 @@ class LogService { fun log(id: String) { println("log = $id") } + fun log(id: String, throwable: Throwable) { + println("log = $id , ${throwable}") + } } @Controller diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt index 63ff6f9..6691ed4 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt @@ -1,86 +1,209 @@ package kr.lunaticbum.back.lun.utils + import kotlin.random.Random -class SudokuGenerator { +/** + * [์ˆ˜์ •] ์ƒ์„ฑ๋œ ์Šค๋„์ฟ  ํผ์ฆ๊ณผ ํ•ด๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค (๊ธฐ์กด๊ณผ ๋™์ผ) + */ +data class SudokuPuzzle( + val solution: String, // N*N x N*N ํฌ๊ธฐ์˜ ์™„์„ฑ๋œ ์ •๋‹ต ๋ฌธ์ž์—ด (์˜ˆ: 81, 256, 625...) + val question: String // ๋นˆ์นธ('0')์ด ํฌํ•จ๋œ ๋ฌธ์ œ ๋ฌธ์ž์—ด +) - // 9x9 ์Šค๋„์ฟ  ๋ณด๋“œ - private val board = Array(9) { IntArray(9) } +/** + * [์ˆ˜์ •] ๋™์  ๊ทธ๋ฆฌ๋“œ ์ƒ์„ฑ์„ ์ง€์›ํ•˜๋Š” ์Šค๋„์ฟ  ์ƒ์„ฑ๊ธฐ + * + * @param blockSize (N) ๋ฐ•์Šค ํ•˜๋‚˜์˜ ํฌ๊ธฐ. (๊ธฐ๋ณธ๊ฐ’: 3) + * - 2: 4x4 (2x2 ๋ธ”๋ก) ๊ทธ๋ฆฌ๋“œ. ์ˆซ์ž 1-4 ์‚ฌ์šฉ. + * - 3: 9x9 (3x3 ๋ธ”๋ก) ๊ทธ๋ฆฌ๋“œ. ์ˆซ์ž 1-9 ์‚ฌ์šฉ. + * - 4: 16x16 (4x4 ๋ธ”๋ก) ๊ทธ๋ฆฌ๋“œ. ์ˆซ์ž 1-9, A-G ์‚ฌ์šฉ. + * - 5: 25x25 (5x5 ๋ธ”๋ก) ๊ทธ๋ฆฌ๋“œ. ์ˆซ์ž 1-9, A-P ์‚ฌ์šฉ. + */ +class SudokuGenerator(private val blockSize: Int = 3) { + + // N*N (์˜ˆ: 3 -> 9, 4 -> 16) + private val gridSize: Int = blockSize * blockSize + // N*N x N*N (์˜ˆ: 3 -> 81, 4 -> 256) + private val totalCells: Int = gridSize * gridSize + + // ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ์— ๋งž๊ฒŒ ๋ณด๋“œ ๋™์  ์ƒ์„ฑ + private val board = Array(gridSize) { IntArray(gridSize) } + + // ํ•ด๋‹ต์˜ ๊ฐœ์ˆ˜๋ฅผ ์„ธ๊ธฐ ์œ„ํ•œ ๋‚ด๋ถ€ ๋ณ€์ˆ˜ + private var solutionCount = 0 /** - * ์™„์„ฑ๋œ ์Šค๋„์ฟ  ํผ์ฆ ํ•˜๋‚˜๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. - * @return 81์ž๋ฆฌ ๋ฌธ์ž์—ด๋กœ ๋œ ํผ์ฆ ๋ฐ์ดํ„ฐ + * [ํ•ต์‹ฌ] ์ง€์ •๋œ ๋ ˆ๋ฒจ์˜ ์Šค๋„์ฟ  ํผ์ฆ๊ณผ ํ•ด๋‹ต์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. */ - fun generate(): String { - // ๋ณด๋“œ ์ดˆ๊ธฐํ™” - for (i in 0..8) { - for (j in 0..8) { - board[i][j] = 0 - } - } - // ๋ฐฑํŠธ๋ž˜ํ‚น์œผ๋กœ ํผ์ฆ ํ’€๊ธฐ ์‹œ์ž‘ - solve() - // 2D ๋ฐฐ์—ด์„ 1D ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜ - return board.joinToString("") { row -> row.joinToString("") } + fun generatePuzzle(level: Int): SudokuPuzzle { + // 1. ๋ณด๋“œ ์ดˆ๊ธฐํ™” + _clearBoard() + + // 2. ๋žœ๋คํ•œ ์Šค๋„์ฟ  ์ •๋‹ต ๋ณด๋“œ 1๊ฐœ ์ƒ์„ฑ + _solve() + val solutionString = _boardToString() + + // 3. ์ •๋‹ต ๋ณด๋“œ์—์„œ ๋นˆ์นธ์„ ๋šซ์–ด ๋ฌธ์ œ๋ฅผ ์ƒ์„ฑ + _pokeHoles(level) + val questionString = _boardToString() + + return SudokuPuzzle(solutionString, questionString) } /** - * ๋ฐฑํŠธ๋ž˜ํ‚น์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ณด๋“œ๋ฅผ ์ฑ„์šฐ๋Š” ์žฌ๊ท€ ํ•จ์ˆ˜ + * [์‹ ๊ทœ] DB์—์„œ ๊ฐ€์ ธ์˜จ solution์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฌธ์ œ๋ฅผ ์ƒ์„ฑ */ - private fun solve(): Boolean { - val emptyCell = findEmpty() ?: return true // ๋นˆ ์นธ์ด ์—†์œผ๋ฉด ์„ฑ๊ณต + fun generatePuzzleFromSolution(solutionString: String, level: Int): SudokuPuzzle { + // 1. DB์˜ ์ •๋‹ต ๋ฌธ์ž์—ด์„ 2D Int ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ + val solutionBoard = _stringToBoard(solutionString) + + // 2. ๋‚ด๋ถ€ 'board' ํ•„๋“œ์— ์ •๋‹ต ๋ณด๋“œ๋ฅผ ๋ณต์‚ฌ + for (i in 0 until gridSize) { + this.board[i] = solutionBoard[i].clone() + } + + // 3. ์ •๋‹ต์ด ์ฑ„์›Œ์ง„ ๋ณด๋“œ์—์„œ ๋นˆ์นธ ๋šซ๊ธฐ ์‹œ์ž‘ + _pokeHoles(level) + val questionString = _boardToString() + + // 4. (์ •๋‹ต, ๋ฌธ์ œ) ๋ฐ˜ํ™˜ + return SudokuPuzzle(solutionString, questionString) + } + + /** + * [์ˆ˜์ •] ๋ ˆ๋ฒจ์— ๋งž์ถฐ '์œ ์ผํ•œ ํ•ด๋‹ต'์„ ๋ณด์žฅํ•˜๋ฉฐ ๋นˆ์นธ์„ ๋šซ๋Š” ํ•จ์ˆ˜ + * ๋‚œ์ด๋„ ๊ธฐ์ค€์„ ๊ณ ์ •๋œ '๋นˆ์นธ ์ˆ˜'๊ฐ€ ์•„๋‹Œ '๋น„์œจ(%)'๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ๋ชจ๋“  ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ์— ๋Œ€์‘ + */ + private fun _pokeHoles(level: Int) { + // ๋‚œ์ด๋„๋ณ„ ์ตœ์†Œ ํด๋ฃจ(์ˆซ์ž) ๋น„์œจ, ์ตœ๋Œ€ ๋นˆ์นธ ๋น„์œจ + val (minCluesRatio, maxHolesRatio) = when (level) { + 1 -> Pair(0.49, 0.51) // ์•„์ฃผ ์‰ฌ์›€ (์ตœ์†Œ 49% ์ˆซ์ž ๋ณด์žฅ / 9x9์—์„œ ์•ฝ 40๊ฐœ) + 2 -> Pair(0.42, 0.58) // ์‰ฌ์›€ (์ตœ์†Œ 42% ์ˆซ์ž ๋ณด์žฅ / 9x9์—์„œ ์•ฝ 34๊ฐœ) + 3 -> Pair(0.37, 0.63) // ์ค‘๊ธ‰ (์ตœ์†Œ 37% ์ˆซ์ž ๋ณด์žฅ / 9x9์—์„œ ์•ฝ 30๊ฐœ) + 4 -> Pair(0.32, 0.68) // ์–ด๋ ค์›€ (์ตœ์†Œ 32% ์ˆซ์ž ๋ณด์žฅ / 9x9์—์„œ ์•ฝ 26๊ฐœ) + else -> Pair(0.27, 0.73) // ์ „๋ฌธ๊ฐ€ (์ตœ์†Œ 27% ์ˆซ์ž ๋ณด์žฅ / 9x9์—์„œ ์•ฝ 22๊ฐœ) + } + + val minClues = (totalCells * minCluesRatio).toInt() + val maxHoles = (totalCells * maxHolesRatio).toInt() + + var holesMade = 0 + // (0..80) -> (0..totalCells-1) + val cells = (0 until totalCells).shuffled(Random) + + for (cellIndex in cells) { + val row = cellIndex / gridSize + val col = cellIndex % gridSize + + // ์ด๋ฏธ ๋นˆ ์นธ์ด๋ฉด ๊ฑด๋„ˆ๋›ฐ๊ธฐ (ํ˜น์‹œ ๋ชจ๋ฅผ ๋ฐฉ์–ด ์ฝ”๋“œ) + if (board[row][col] == 0) continue + + val temp = board[row][col] + board[row][col] = 0 + + // [!!!] ํ•ต์‹ฌ ์ˆ˜์ • ์ง€์  [!!!] + // 9x9 (gridSize=9, blockSize=3) ์ดํ•˜์—์„œ๋งŒ ์œ ์ผ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + var isUnique = true // ๊ธฐ๋ณธ๊ฐ’์€ '์œ ์ผํ•˜๋‹ค'๋กœ ๊ฐ€์ • + if (gridSize <= 9) { + solutionCount = 0 + _countSolutions() + isUnique = (solutionCount == 1) + } + // [!!!] ์ˆ˜์ • ๋ [!!!] + + if (!isUnique) { + // 9x9์—์„œ ์œ ์ผ ํ•ด๊ฐ€ ๊นจ์กŒ๋‹ค๋ฉด ์ˆซ์ž๋ฅผ ๋ณต์› + board[row][col] = temp + } else { + // 9x9์—์„œ ์œ ์ผ ํ•ด๊ฐ€ ์œ ์ง€๋˜๊ฑฐ๋‚˜, + // 16x16 ์ด์ƒ์ด๋ผ์„œ ๊ฒ€์‚ฌ๋ฅผ ๊ฑด๋„ˆ๋›ฐ์—ˆ๋‹ค๋ฉด ๊ตฌ๋ฉ ๋šซ๊ธฐ ์„ฑ๊ณต + holesMade++ + } + + val remainingClues = totalCells - holesMade + if (holesMade >= maxHoles || (remainingClues <= minClues && holesMade > 0)) { + break + } + } + } + + /** + * [์ˆ˜์ •] ํ˜„์žฌ board ์ƒํƒœ์—์„œ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ํ•ด๋‹ต์˜ '๊ฐœ์ˆ˜'๋ฅผ ์„ธ๋Š” ํ•จ์ˆ˜ + */ + private fun _countSolutions() { + val empty = _findEmpty(board) + if (empty == null) { + solutionCount++ + return + } + + val (row, col) = empty + // (1..9) -> (1..gridSize) + for (num in 1..gridSize) { + if (_isValid(num, row, col, board)) { + board[row][col] = num + _countSolutions() + board[row][col] = 0 // ๋ฐฑํŠธ๋ž˜ํ‚น + + if (solutionCount > 1) return + } + } + } + + /** + * [์ˆ˜์ •] ๋ฐฑํŠธ๋ž˜ํ‚น์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ณด๋“œ๋ฅผ ์ฑ„์šฐ๋Š” ์žฌ๊ท€ ํ•จ์ˆ˜ + */ + private fun _solve(): Boolean { + val emptyCell = _findEmpty(board) ?: return true val row = emptyCell.first val col = emptyCell.second - // 1~9 ์ˆซ์ž๋ฅผ ๋ฌด์ž‘์œ„๋กœ ์„ž์–ด์„œ ์‹œ๋„ํ•˜๋ฉด ๋งค๋ฒˆ ๋‹ค๋ฅธ ํŒจํ„ด์˜ ํผ์ฆ์ด ์ƒ์„ฑ๋จ - val numbers = (1..9).shuffled(Random) + // (1..9) -> (1..gridSize) + val numbers = (1..gridSize).shuffled(Random) for (num in numbers) { - if (isValid(num, row, col)) { - // ์œ ํšจํ•œ ์ˆซ์ž๋ฅผ ์ฐพ์œผ๋ฉด ์นธ์— ๋ฐฐ์น˜ + if (_isValid(num, row, col, board)) { board[row][col] = num - - // ์žฌ๊ท€์ ์œผ๋กœ ๋‹ค์Œ ์นธ ํ’€์ด ์‹œ๋„ - if (solve()) { - return true // ๋๊นŒ์ง€ ํ•ด๊ฒฐ๋˜๋ฉด ์„ฑ๊ณต + if (_solve()) { + return true } - - // ๋‹ค์Œ ์นธ ํ’€์ด์— ์‹คํŒจํ•˜๋ฉด, ํ˜„์žฌ ์นธ์„ ๋น„์šฐ๊ณ  ๋‹ค๋ฅธ ์ˆซ์ž๋กœ ๋‹ค์‹œ ์‹œ๋„ (๋ฐฑํŠธ๋ž˜ํ‚น) - board[row][col] = 0 + board[row][col] = 0 // ๋ฐฑํŠธ๋ž˜ํ‚น } } - return false // 1~9๊นŒ์ง€ ๋ชจ๋“  ์ˆซ์ž๋ฅผ ์‹œ๋„ํ•ด๋„ ํ•ด๊ฒฐ ๋ชปํ•˜๋ฉด ์‹คํŒจ + return false } /** - * ํŠน์ • ์œ„์น˜์— ์ˆซ์ž๋ฅผ ๋†“๋Š” ๊ฒƒ์ด ์œ ํšจํ•œ์ง€ ๊ฒ€์‚ฌ + * [์ˆ˜์ •] ํŠน์ • ์œ„์น˜์— ์ˆซ์ž๋ฅผ ๋†“๋Š” ๊ฒƒ์ด ์œ ํšจํ•œ์ง€ ๊ฒ€์‚ฌ (blockSize ๊ธฐ๋ฐ˜) */ - private fun isValid(num: Int, row: Int, col: Int): Boolean { - // 1. ๊ฐ€๋กœ์ค„ ๊ฒ€์‚ฌ - for (c in 0..8) { + private fun _isValid(num: Int, row: Int, col: Int, board: Array): Boolean { + // (0..8) -> (0..gridSize-1) + for (c in 0 until gridSize) { if (board[row][c] == num) return false } - // 2. ์„ธ๋กœ์ค„ ๊ฒ€์‚ฌ - for (r in 0..8) { + for (r in 0 until gridSize) { if (board[r][col] == num) return false } - // 3. 3x3 ๋ฐ•์Šค ๊ฒ€์‚ฌ - val boxStartRow = row - row % 3 - val boxStartCol = col - col % 3 - for (r in 0..2) { - for (c in 0..2) { + + // (3x3 ๋ฐ•์Šค) -> (NxN ๋ฐ•์Šค) + val boxStartRow = row - row % blockSize + val boxStartCol = col - col % blockSize + for (r in 0 until blockSize) { + for (c in 0 until blockSize) { if (board[boxStartRow + r][boxStartCol + c] == num) return false } } - return true // ๋ชจ๋“  ๊ฒ€์‚ฌ๋ฅผ ํ†ต๊ณผํ•˜๋ฉด ์œ ํšจํ•จ + return true } /** - * ๋ณด๋“œ์—์„œ ๋น„์–ด์žˆ๋Š” ์ฒซ ๋ฒˆ์งธ ์นธ์˜ ์ขŒํ‘œ๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค. (row, col) + * [์ˆ˜์ •] ๋ณด๋“œ์—์„œ ๋น„์–ด์žˆ๋Š” ์ฒซ ๋ฒˆ์งธ ์นธ์˜ ์ขŒํ‘œ๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค. */ - private fun findEmpty(): Pair? { - for (r in 0..8) { - for (c in 0..8) { + private fun _findEmpty(board: Array): Pair? { + // (0..8) -> (0..gridSize-1) + for (r in 0 until gridSize) { + for (c in 0 until gridSize) { if (board[r][c] == 0) { return Pair(r, c) } @@ -88,4 +211,91 @@ class SudokuGenerator { } return null } + + /** + * [์ˆ˜์ •] ๋ณด๋“œ๋ฅผ 0์œผ๋กœ ๋ชจ๋‘ ์ดˆ๊ธฐํ™” + */ + private fun _clearBoard() { + for (i in 0 until gridSize) { + for (j in 0 until gridSize) { + board[i][j] = 0 + } + } + } + + /** + * [์‹ ๊ทœ] 10 ์ด์ƒ์˜ ์ˆซ์ž๋ฅผ 'A', 'B' ๋“ฑ์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ—ฌํผ + */ + private fun _intToChar(num: Int): Char { + return when (num) { + 0 -> '0' + in 1..9 -> num.toString()[0] + in 10..35 -> ('A' + (num - 10)) // 10=A, 11=B ... 35=Z + else -> '?' // 36 ์ด์ƒ์€ ์ง€์› ์•ˆ ํ•จ + } + } + + /** + * [์‹ ๊ทœ] 10์ง„์ˆ˜ ๋ณด๋“œ(IntArray)๋ฅผ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ (10 -> A) + */ + private fun _boardToString(): String { + return board.joinToString("") { row -> + row.joinToString("") { cell -> _intToChar(cell).toString() } + } + } + + // [์‹ ๊ทœ] ๋ฌธ์ž(A)๋ฅผ ๋‹ค์‹œ 10์ง„์ˆ˜(10)๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ—ฌํผ + private fun _charToInt(char: Char): Int { + return when (char) { + '0' -> 0 + in '1'..'9' -> char.toString().toInt() + in 'A'..'Z' -> (char - 'A') + 10 + else -> -1 // Invalid + } + } + + // [์‹ ๊ทœ] ๋ฌธ์ž์—ด ๋ณด๋“œ๋ฅผ 10์ง„์ˆ˜ IntArray๋กœ ๋ณ€ํ™˜ (A -> 10) + private fun _stringToBoard(puzzleString: String): Array { + val newBoard = Array(gridSize) { IntArray(gridSize) } + for (i in 0 until totalCells) { + val row = i / gridSize + val col = i % gridSize + newBoard[row][col] = _charToInt(puzzleString[i]) + } + return newBoard + } + + // [์‹ ๊ทœ] ์™ธ๋ถ€์—์„œ ์ •๋‹ต ๊ฒ€์ฆ์„ ์š”์ฒญํ•  ๋•Œ ์‚ฌ์šฉํ•  ๊ณต๊ฐœ ํ•จ์ˆ˜ + fun validate(questionString: String, answerString: String): Boolean { + if (answerString.length != totalCells || questionString.length != totalCells) { + return false // ํฌ๊ธฐ๊ฐ€ ๋‹ค๋ฆ„ + } + if (answerString.contains('0')) { + return false // 0์„ ํฌํ•จ (๋ฏธ์™„์„ฑ) + } + + val answerBoard = _stringToBoard(answerString) + val questionBoard = _stringToBoard(questionString) + + for (r in 0 until gridSize) { + for (c in 0 until gridSize) { + val answerNum = answerBoard[r][c] + val questionNum = questionBoard[r][c] + + // 1. ๋‹ต์•ˆ์ด ์œ ํšจํ•œ์ง€ (๊ทœ์น™ ์œ„๋ฐ˜) + answerBoard[r][c] = 0 // ์ž„์‹œ๋กœ ๋น„์›Œ์„œ ๊ฒ€์‚ฌ + if (!_isValid(answerNum, r, c, answerBoard)) { + return false + } + answerBoard[r][c] = answerNum // ์›์ƒ๋ณต๊ตฌ + + // 2. ๋‹ต์•ˆ์ด ์›๋ณธ ๋ฌธ์ œ์™€ ์ผ์น˜ํ•˜๋Š”์ง€ (์›๋ณธ ์ˆซ์ž๋ฅผ ๋ฐ”๊ฟจ๋Š”์ง€) + if (questionNum != 0 && questionNum != answerNum) { + return false + } + } + } + + return true // ๋ชจ๋“  ๊ฒ€์ฆ ํ†ต๊ณผ + } } \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/spider.html b/src/main/resources/templates/content/puzzle/spider.html index b3a8971..67cdf82 100644 --- a/src/main/resources/templates/content/puzzle/spider.html +++ b/src/main/resources/templates/content/puzzle/spider.html @@ -30,7 +30,7 @@ } -