This commit is contained in:
lunaticbum 2025-11-10 18:02:19 +09:00
parent 5ac3b05660
commit 73072d1812
9 changed files with 584 additions and 123 deletions

View File

@ -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

View File

@ -36,7 +36,7 @@ class BumsInterceptor(
// if (!skippResourcesExtension) {
// println("===============================================")
// println("==================== BEGIN ====================")
// println("Request URL ===> " + request.requestURL)
println("Request URL ===> " + request.requestURL)
// }

View File

@ -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")
}
}
}
}

View File

@ -159,6 +159,7 @@ class SecurityConfig(
)
}.authorizeHttpRequests { auth ->
auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 1. 정적 리소스 = permitAll
.requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"

View File

@ -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))
}
}

View File

@ -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<String, Boolean>()
/**
* [핵심 수정]
* - 파라미터를 '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<SpiderGame, String> {
/**
* 스도쿠 퍼즐 원본 데이터를 저장하는 모델
*/
@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<SudokuPuzzle, String> {
// 🔽 [수정] 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<GameRank, String> {
// [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
// 🔽 [추가]
fun findFirstByUserId(userId: String): Mono<GameRank>
fun findByPlayerName(playerName: String): Flux<GameRank> // 이름 중복 확인용
}
@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<GameRank> {
return when (gameType) {
@ -595,50 +714,99 @@ class GameRankService(
}
/**
* [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
* [수정] 공통 DTO를 받아 랭킹을 저장 (Blocking IO 모든 예외 처리)
*/
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
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<GameRank>
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<GameRank> { 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<GameRank> {
// 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<GameRank>()
}
}
.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<GameRank>()
}
}
})
// 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<GameRank> {
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)

View File

@ -52,6 +52,9 @@ class LogService {
fun log(id: String) {
println("log = $id")
}
fun log(id: String, throwable: Throwable) {
println("log = $id , ${throwable}")
}
}
@Controller

View File

@ -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<IntArray>): 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<Int, Int>? {
for (r in 0..8) {
for (c in 0..8) {
private fun _findEmpty(board: Array<IntArray>): Pair<Int, Int>? {
// (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<IntArray> {
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 // 모든 검증 통과
}
}

View File

@ -30,7 +30,7 @@
}
</style>
<script type="text/javascript">
//<![CDATA[
window.pageContext = { pageType: 'game', gameType: 'SPIDER', contextId: undefined };