diff --git a/src/main/java/com/snackgame/server/game/metadata/Metadata.kt b/src/main/java/com/snackgame/server/game/metadata/Metadata.kt index b23fc69..31cf94c 100644 --- a/src/main/java/com/snackgame/server/game/metadata/Metadata.kt +++ b/src/main/java/com/snackgame/server/game/metadata/Metadata.kt @@ -9,4 +9,5 @@ enum class Metadata( SNACK_GAME(2, "스낵게임"), SNACK_GAME_INFINITE(3, "스낵게임 무한모드"), SNACK_GAME_BIZ(4, "스낵게임 Biz"), + SNACK_GAME_BIZ_V2(5, "스낵게임 Biz"), } diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/controller/SnackgameBizV2Controller.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/controller/SnackgameBizV2Controller.kt new file mode 100644 index 0000000..3c306e8 --- /dev/null +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/controller/SnackgameBizV2Controller.kt @@ -0,0 +1,118 @@ +package com.snackgame.server.game.snackgame.biz.controller + +import com.snackgame.server.auth.token.util.JwtProvider +import com.snackgame.server.game.sign.service.Signed +import com.snackgame.server.game.snackgame.biz.service.SnackgameBizV2Service +import com.snackgame.server.game.snackgame.biz.service.dto.SnackgameBizStartResponse +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.StreaksRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.InetAddress +import kotlin.random.Random + +@Tag(name = "🍿 스낵게임 Biz V2") +@RequestMapping("/games/5") +@RestController +class SnackgameBizV2Controller( + private val snackgameBizService: SnackgameBizV2Service, + private val accessTokenProvider: JwtProvider +) { + + @Operation( + summary = "스낵게임 세션 시작", + description = """ +스낵게임 세션을 시작한다.""" + ) + @PostMapping + fun startSessionFor( + @RequestHeader("X-Forwarded-For", required = false) originalIp: String? + ): SnackgameBizStartResponse { + // TODO: 사용자를 직접 식별하여 id를 만든다. + val temporaryUserId = originalIp?.let { ipToLong(it) } ?: Random.nextLong() + + val game = snackgameBizService.startSessionFor(temporaryUserId) + + val token = accessTokenProvider.createTokenWith(temporaryUserId.toString()) + + return SnackgameBizStartResponse.of(game, token) + } + + // TODO: 사용자를 직접 식별하여 id를 만든다. + fun ipToLong(ip: String): Long { + val inetAddress = InetAddress.getByName(ip) + val addressBytes = inetAddress.address + var result: Long = 0 + for (byte in addressBytes) { + result = (result shl 8) or (byte.toInt() and 0xFF).toLong() + } + return result + } + + @Operation( + summary = "스트릭 추가", + description = """ +스트릭을 순서대로 전달하여 게임을 검증한다. +황금 스낵을 제거한 경우 세션 정보가 함께 응답된다. +""" + ) + @PostMapping("/{sessionId}/streaks") + fun removeStreaks( + @RequestHeader("Authorization") authorization: String, + @PathVariable sessionId: Long, + @RequestBody streaksRequest: StreaksRequest + ): ResponseEntity { + val memberId = accessTokenProvider.getSubjectFrom(authorization).toLong() + val game = snackgameBizService.removeStreaks(memberId, sessionId, streaksRequest) + + return ResponseEntity + .status(HttpStatus.OK) + .body(game) + } + + @Operation( + summary = "스낵게임 세션 일시정지", + description = """ +해당 세션이 일시정지된다. + +일시정지된 세션은 별도로 종료하지 않아도 **7일** 후 자동으로 만료된다.""" + ) + @PostMapping("/{sessionId}/pause") + fun pause( + @RequestHeader("Authorization") authorization: String, + @PathVariable sessionId: Long + ): SnackgameResponse { + val memberId = accessTokenProvider.getSubjectFrom(authorization).toLong() + return snackgameBizService.pause(memberId, sessionId) + } + + @Operation(summary = "스낵게임 세션 재개", description = "해당 세션을 재개한다") + @PostMapping("/{sessionId}/resume") + fun resume( + @RequestHeader("Authorization") authorization: String, + @PathVariable sessionId: Long + ): SnackgameResponse { + val memberId = accessTokenProvider.getSubjectFrom(authorization).toLong() + return snackgameBizService.resume(memberId, sessionId) + } + + @Signed + @Operation(summary = "스낵게임 세션 종료", description = "세션을 종료한다") + @PostMapping("/{sessionId}/end") + fun end( + @RequestHeader("Authorization") authorization: String, + @PathVariable sessionId: Long + ): SnackgameEndResponse { + val memberId = accessTokenProvider.getSubjectFrom(authorization).toLong() + return snackgameBizService.end(memberId, sessionId) + } +} 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 new file mode 100644 index 0000000..0401262 --- /dev/null +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2.kt @@ -0,0 +1,40 @@ +package com.snackgame.server.game.snackgame.biz.domain + +import com.snackgame.server.game.metadata.Metadata.SNACK_GAME_BIZ_V2 +import com.snackgame.server.game.session.domain.Session +import com.snackgame.server.game.snackgame.core.domain.Board +import com.snackgame.server.game.snackgame.core.domain.BoardConverter +import com.snackgame.server.game.snackgame.core.domain.Snackgame.Companion.DEFAULT_HEIGHT +import com.snackgame.server.game.snackgame.core.domain.Snackgame.Companion.DEFAULT_WIDTH +import com.snackgame.server.game.snackgame.core.domain.Snackgame.Companion.SESSION_TIME +import com.snackgame.server.game.snackgame.core.domain.Snackgame.Companion.SPARE_TIME +import com.snackgame.server.game.snackgame.core.domain.Streak +import com.snackgame.server.game.snackgame.core.domain.snack.Snack +import java.time.Duration +import javax.persistence.Convert +import javax.persistence.Entity +import javax.persistence.Lob + +@Entity +open class SnackgameBizV2( + ownerId: Long, + board: Board = Board(DEFAULT_HEIGHT, DEFAULT_WIDTH), + timeLimit: Duration = SESSION_TIME + SPARE_TIME, + score: Int = 0 +) : Session(ownerId, timeLimit, score) { + + @Lob + @Convert(converter = BoardConverter::class) + var board = board + private set + + fun remove(streak: Streak) { + val removedSnacks = board.removeSnacksIn(streak) + this.score += removedSnacks.size + if (removedSnacks.any(Snack::isGolden)) { + this.board = board.reset() + } + } + + override val metadata = SNACK_GAME_BIZ_V2 +} diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2Repository.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2Repository.kt new file mode 100644 index 0000000..bc6947e --- /dev/null +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2Repository.kt @@ -0,0 +1,31 @@ +package com.snackgame.server.game.snackgame.biz.domain + +import com.snackgame.server.game.session.exception.NoSuchSessionException +import com.snackgame.server.game.snackgame.core.domain.Percentile +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface SnackgameBizV2Repository : JpaRepository { + fun findByOwnerIdAndSessionId(ownerId: Long, sessionId: Long): SnackgameBizV2? + + @Query( + value = """ + with scores as ( + select percent_rank() over (order by score desc) as percentile, session_id, score + from snackgame_biz_v2 where TIMESTAMPDIFF(SECOND, now(), expires_at) <=0 + ) + select percentile from scores where session_id = :sessionId""", + nativeQuery = true + ) + fun findPercentileOf(sessionId: Long): Double? +} + +fun SnackgameBizV2Repository.getBy(ownerId: Long, sessionId: Long): SnackgameBizV2 = + findByOwnerIdAndSessionId(ownerId, sessionId) ?: throw NoSuchSessionException() + +fun SnackgameBizV2Repository.ratePercentileOf(sessionId: Long): Percentile { + with(findPercentileOf(sessionId)) { + this ?: throw NoSuchSessionException() + return Percentile(this) + } +} 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 new file mode 100644 index 0000000..6614419 --- /dev/null +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/service/SnackgameBizV2Service.kt @@ -0,0 +1,66 @@ +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 +import com.snackgame.server.game.snackgame.biz.domain.ratePercentileOf +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.StreaksRequest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SnackgameBizV2Service( + private val snackgameBizRepository: SnackgameBizV2Repository, + private val eventPublisher: ApplicationEventPublisher, +) { + + @Transactional + fun startSessionFor(memberId: Long): SnackgameResponse { + val game = snackgameBizRepository.save(SnackgameBizV2(memberId)) + + return SnackgameResponse.of(game) + } + + @Transactional + fun removeStreaks(memberId: Long, sessionId: Long, streaksRequest: StreaksRequest): SnackgameResponse { + val game = snackgameBizRepository.getBy(memberId, sessionId) + + streaksRequest.toStreaks() + .forEach { game.remove(it) } + + return SnackgameResponse.of(game) + } + + @Transactional + fun pause(memberId: Long, sessionId: Long): SnackgameResponse { + val game = snackgameBizRepository.getBy(memberId, sessionId) + + game.pause() + + return SnackgameResponse.of(game) + } + + @Transactional + fun resume(memberId: Long, sessionId: Long): SnackgameResponse { + val game = snackgameBizRepository.getBy(memberId, sessionId) + + game.resume() + + return SnackgameResponse.of(game) + } + + @Transactional + fun end(memberId: Long, sessionId: Long): SnackgameEndResponse { + val game = snackgameBizRepository.getBy(memberId, sessionId) + + game.end() + eventPublisher.publishEvent(SessionEndEvent.of(game)) + + return SnackgameEndResponse.of(game, snackgameBizRepository.ratePercentileOf(sessionId)) + } +} diff --git a/src/main/java/com/snackgame/server/game/snackgame/biz/service/dto/SnackgameBizStartResponse.kt b/src/main/java/com/snackgame/server/game/snackgame/biz/service/dto/SnackgameBizStartResponse.kt new file mode 100644 index 0000000..e1f395d --- /dev/null +++ b/src/main/java/com/snackgame/server/game/snackgame/biz/service/dto/SnackgameBizStartResponse.kt @@ -0,0 +1,36 @@ +package com.snackgame.server.game.snackgame.biz.service.dto + +import com.snackgame.server.game.metadata.MetadataResponse +import com.snackgame.server.game.session.domain.SessionStateType +import com.snackgame.server.game.snackgame.core.service.dto.SnackResponse +import com.snackgame.server.game.snackgame.core.service.dto.SnackgameResponse +import java.time.LocalDateTime + +data class SnackgameBizStartResponse( + val metadata: MetadataResponse, + val ownerId: Long, + val sessionId: Long, + val state: SessionStateType, + val score: Int, + val createdAt: LocalDateTime, + val board: List>, + val token: String +) { + + companion object { + + fun of(snackgame: SnackgameResponse, token: String): SnackgameBizStartResponse { + return SnackgameBizStartResponse( + snackgame.metadata, + snackgame.ownerId, + snackgame.sessionId, + snackgame.state, + snackgame.score, + snackgame.createdAt, + snackgame.board, + token + ) + } + } +} + diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameEndResponse.kt b/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameEndResponse.kt index d6d60a2..c437677 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameEndResponse.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameEndResponse.kt @@ -4,6 +4,7 @@ package com.snackgame.server.game.snackgame.core.service.dto import com.snackgame.server.game.session.domain.Session import com.snackgame.server.game.session.service.dto.SessionResponse import com.snackgame.server.game.snackgame.biz.domain.SnackgameBiz +import com.snackgame.server.game.snackgame.biz.domain.SnackgameBizV2 import com.snackgame.server.game.snackgame.core.domain.Percentile import com.snackgame.server.game.snackgame.core.domain.Snackgame import com.snackgame.server.game.snackgame.infinite.domain.SnackgameInfinite @@ -36,5 +37,12 @@ class SnackgameEndResponse( percentile.percentage() ) } + + fun of(snackgame: SnackgameBizV2, percentile: Percentile): SnackgameEndResponse { + return SnackgameEndResponse( + snackgame, + percentile.percentage() + ) + } } } diff --git a/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameResponse.kt b/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameResponse.kt index f1227fa..0c2a018 100644 --- a/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameResponse.kt +++ b/src/main/java/com/snackgame/server/game/snackgame/core/service/dto/SnackgameResponse.kt @@ -3,6 +3,7 @@ package com.snackgame.server.game.snackgame.core.service.dto import com.snackgame.server.game.session.domain.Session import com.snackgame.server.game.session.service.dto.SessionResponse import com.snackgame.server.game.snackgame.biz.domain.SnackgameBiz +import com.snackgame.server.game.snackgame.biz.domain.SnackgameBizV2 import com.snackgame.server.game.snackgame.core.domain.Snackgame import com.snackgame.server.game.snackgame.infinite.domain.SnackgameInfinite @@ -20,18 +21,25 @@ class SnackgameResponse( ) } - fun of(snackgame: SnackgameInfinite): SnackgameResponse { + fun of(snackgame: SnackgameBiz): SnackgameResponse { return SnackgameResponse( snackgame, - listOf() // TODO: bring Board in + SnackResponse.of(snackgame.board.getSnacks()) ) } - fun of(snackgame: SnackgameBiz): SnackgameResponse { + fun of(snackgame: SnackgameBizV2): SnackgameResponse { return SnackgameResponse( snackgame, SnackResponse.of(snackgame.board.getSnacks()) ) } + + fun of(snackgame: SnackgameInfinite): SnackgameResponse { + return SnackgameResponse( + snackgame, + listOf() // TODO: bring Board in + ) + } } } diff --git a/src/main/java/com/snackgame/server/status/StatusService.kt b/src/main/java/com/snackgame/server/status/StatusService.kt index 3736a26..1cbbe31 100644 --- a/src/main/java/com/snackgame/server/status/StatusService.kt +++ b/src/main/java/com/snackgame/server/status/StatusService.kt @@ -20,6 +20,8 @@ class StatusService(private val memberRepository: MemberRepository) { Metadata.APPLE_GAME, Metadata.SNACK_GAME_INFINITE, Metadata.SNACK_GAME_BIZ -> addExpWithScore(session) + + else -> {} } } diff --git a/src/test/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2Test.kt b/src/test/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2Test.kt new file mode 100644 index 0000000..c81bbfe --- /dev/null +++ b/src/test/java/com/snackgame/server/game/snackgame/biz/domain/SnackgameBizV2Test.kt @@ -0,0 +1,61 @@ +@file:Suppress("NonAsciiCharacters") + +package com.snackgame.server.game.snackgame.biz.domain + +import com.snackgame.server.game.snackgame.core.domain.Coordinate +import com.snackgame.server.game.snackgame.core.domain.Streak +import com.snackgame.server.game.snackgame.fixture.TestFixture.THREE_BY_FOUR +import com.snackgame.server.game.snackgame.fixture.TestFixture.TWO_BY_TWO_WITH_GOLDEN_SNACK +import com.snackgame.server.member.fixture.MemberFixture.땡칠 +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class SnackgameBizV2Test { + + @ParameterizedTest + @MethodSource("길이가 각각 다른 스트릭들") + fun `제거한 스트릭 길이만큼 점수를 얻는다`(coordinates: List) { + val game = SnackgameBizV2(땡칠().id, THREE_BY_FOUR()) + + val streak = Streak(coordinates) + game.remove(streak) + + assertThat(game.score).isEqualTo(streak.length) + } + + @Test + fun `황금 스낵을 제거하면 보드가 초기화된다`() { + val game = SnackgameBizV2(땡칠().id, TWO_BY_TWO_WITH_GOLDEN_SNACK()) + + Streak( + listOf( + Coordinate(0, 0), + Coordinate(1, 0) + ) + ).let { game.remove(it) } + + assertThat(game.board).isNotEqualTo(TWO_BY_TWO_WITH_GOLDEN_SNACK()) + } + + companion object { + + @JvmStatic + fun `길이가 각각 다른 스트릭들`(): Stream = Stream.of( + Arguments.of( + listOf( + Coordinate(1, 0), + Coordinate(0, 0) + ), + listOf( + Coordinate(2, 1), + Coordinate(1, 0), + Coordinate(0, 0) + ) + ) + ) + } +}