Add Maia chess gameplay and deployment
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
|
||||
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessMoveRequest
|
||||
import me.wypark.blogbackend.domain.auth.CustomUserDetails
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameService
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chess/games")
|
||||
class ChessGameController(
|
||||
private val chessGameService: ChessGameService
|
||||
) {
|
||||
|
||||
@PostMapping
|
||||
fun createGame(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@Valid @RequestBody request: ChessGameCreateRequest
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(chessGameService.createGame(userDetails.memberId, request)))
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun getGames(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PageableDefault(size = 20, sort = ["updatedAt"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||
): ResponseEntity<ApiResponse<Page<ChessGameSummaryResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGames(userDetails.memberId, pageable)))
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
fun getStats(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails
|
||||
): ResponseEntity<ApiResponse<ChessGameStatsResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getStats(userDetails.memberId)))
|
||||
}
|
||||
|
||||
@GetMapping("/{gameId}")
|
||||
fun getGame(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGame(userDetails.memberId, gameId)))
|
||||
}
|
||||
|
||||
@GetMapping("/{gameId}/pgn")
|
||||
fun getPgn(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String
|
||||
): ResponseEntity<ApiResponse<ChessGamePgnResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getPgn(userDetails.memberId, gameId)))
|
||||
}
|
||||
|
||||
@PostMapping("/{gameId}/moves")
|
||||
fun playMove(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String,
|
||||
@Valid @RequestBody request: ChessMoveRequest
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request)))
|
||||
}
|
||||
}
|
||||
153
src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt
Normal file
153
src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
import jakarta.validation.constraints.DecimalMax
|
||||
import jakarta.validation.constraints.DecimalMin
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.Pattern
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameHistoryStats
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameRecord
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameSession
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class ChessGameCreateRequest(
|
||||
@field:Min(600, message = "레이팅은 600 이상이어야 합니다.")
|
||||
@field:Max(2600, message = "레이팅은 2600 이하여야 합니다.")
|
||||
val rating: Int = 1500,
|
||||
|
||||
@field:Pattern(
|
||||
regexp = "(?i)^(white|black)$",
|
||||
message = "playerColor는 white 또는 black이어야 합니다."
|
||||
)
|
||||
val playerColor: String = "white",
|
||||
|
||||
@field:Pattern(
|
||||
regexp = "^(3m|5m|23m|79m)$",
|
||||
message = "model은 3m, 5m, 23m, 79m 중 하나여야 합니다."
|
||||
)
|
||||
val model: String = "5m",
|
||||
|
||||
@field:DecimalMin(value = "0.0", message = "temperature는 0.0 이상이어야 합니다.")
|
||||
@field:DecimalMax(value = "2.0", message = "temperature는 2.0 이하여야 합니다.")
|
||||
val temperature: Double = 0.8,
|
||||
|
||||
@field:DecimalMin(value = "0.0", message = "topP는 0.0 이상이어야 합니다.")
|
||||
@field:DecimalMax(value = "1.0", message = "topP는 1.0 이하여야 합니다.")
|
||||
val topP: Double = 0.95
|
||||
)
|
||||
|
||||
data class ChessMoveRequest(
|
||||
@field:Pattern(
|
||||
regexp = "^[a-h][1-8][a-h][1-8][qrbn]?$",
|
||||
message = "move는 UCI 형식이어야 합니다. 예: e2e4, e7e8q"
|
||||
)
|
||||
val move: String
|
||||
)
|
||||
|
||||
data class ChessGameResponse(
|
||||
val gameId: String,
|
||||
val rating: Int,
|
||||
val playerColor: String,
|
||||
val model: String,
|
||||
val fen: String,
|
||||
val turn: String,
|
||||
val moves: List<String>,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val outcome: String,
|
||||
val pgn: String,
|
||||
val maiaMove: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(session: ChessGameSession, maiaMove: String? = null): ChessGameResponse {
|
||||
return ChessGameResponse(
|
||||
gameId = session.gameId,
|
||||
rating = session.rating,
|
||||
playerColor = session.playerColor.value,
|
||||
model = session.model,
|
||||
fen = session.fen,
|
||||
turn = session.turn.value,
|
||||
moves = session.moves,
|
||||
status = session.status,
|
||||
result = session.result,
|
||||
outcome = session.outcome().name,
|
||||
pgn = session.pgn,
|
||||
maiaMove = maiaMove
|
||||
)
|
||||
}
|
||||
|
||||
fun from(record: ChessGameRecord, maiaMove: String? = null): ChessGameResponse {
|
||||
return ChessGameResponse(
|
||||
gameId = record.gameId,
|
||||
rating = record.rating,
|
||||
playerColor = record.playerColor.value,
|
||||
model = record.model,
|
||||
fen = record.fen,
|
||||
turn = record.turn.value,
|
||||
moves = record.moveList(),
|
||||
status = record.status,
|
||||
result = record.result,
|
||||
outcome = record.outcome.name,
|
||||
pgn = record.pgn,
|
||||
maiaMove = maiaMove
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChessGameSummaryResponse(
|
||||
val gameId: String,
|
||||
val rating: Int,
|
||||
val playerColor: String,
|
||||
val model: String,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val outcome: String,
|
||||
val movesCount: Int,
|
||||
val createdAt: LocalDateTime,
|
||||
val updatedAt: LocalDateTime
|
||||
) {
|
||||
companion object {
|
||||
fun from(record: ChessGameRecord): ChessGameSummaryResponse {
|
||||
return ChessGameSummaryResponse(
|
||||
gameId = record.gameId,
|
||||
rating = record.rating,
|
||||
playerColor = record.playerColor.value,
|
||||
model = record.model,
|
||||
status = record.status,
|
||||
result = record.result,
|
||||
outcome = record.outcome.name,
|
||||
movesCount = record.moveList().size,
|
||||
createdAt = record.createdAt,
|
||||
updatedAt = record.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChessGamePgnResponse(
|
||||
val gameId: String,
|
||||
val pgn: String
|
||||
)
|
||||
|
||||
data class ChessGameStatsResponse(
|
||||
val total: Long,
|
||||
val inProgress: Long,
|
||||
val wins: Long,
|
||||
val losses: Long,
|
||||
val draws: Long,
|
||||
val unknown: Long
|
||||
) {
|
||||
companion object {
|
||||
fun from(stats: ChessGameHistoryStats): ChessGameStatsResponse {
|
||||
return ChessGameStatsResponse(
|
||||
total = stats.total,
|
||||
inProgress = stats.inProgress,
|
||||
wins = stats.wins,
|
||||
losses = stats.losses,
|
||||
draws = stats.draws,
|
||||
unknown = stats.unknown
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ class CorsConfig {
|
||||
// 2. 신뢰할 수 있는 출처(Origin) 명시
|
||||
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
|
||||
config.addAllowedOrigin("https://blog.wypark.me")
|
||||
config.addAllowedOrigin("http://localhost:3000")
|
||||
config.addAllowedOrigin("http://localhost:5173")
|
||||
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
|
||||
|
||||
// 3. 허용할 HTTP 메서드 및 헤더
|
||||
@@ -46,4 +48,4 @@ class CorsConfig {
|
||||
source.registerCorsConfiguration("/api/**", config)
|
||||
return CorsFilter(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package me.wypark.blogbackend.core.config
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.client.RestClient
|
||||
import java.time.Duration
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(MaiaProperties::class)
|
||||
class MaiaConfig {
|
||||
|
||||
@Bean
|
||||
fun maiaRestClient(
|
||||
builder: RestClient.Builder,
|
||||
properties: MaiaProperties
|
||||
): RestClient {
|
||||
return builder
|
||||
.baseUrl(properties.engineUrl)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(prefix = "maia")
|
||||
data class MaiaProperties(
|
||||
val engineUrl: String = "http://localhost:8000",
|
||||
val gameSessionTtl: Duration = Duration.ofHours(6)
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.domain.user.MemberRepository
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
interface ChessGameHistoryStore {
|
||||
|
||||
fun save(session: ChessGameSession)
|
||||
|
||||
fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord?
|
||||
|
||||
fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
|
||||
|
||||
fun getStats(memberId: Long): ChessGameHistoryStats
|
||||
}
|
||||
|
||||
data class ChessGameHistoryStats(
|
||||
val total: Long,
|
||||
val inProgress: Long,
|
||||
val wins: Long,
|
||||
val losses: Long,
|
||||
val draws: Long,
|
||||
val unknown: Long
|
||||
)
|
||||
|
||||
@Repository
|
||||
class JpaChessGameHistoryStore(
|
||||
private val chessGameRecordRepository: ChessGameRecordRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) : ChessGameHistoryStore {
|
||||
|
||||
@Transactional
|
||||
override fun save(session: ChessGameSession) {
|
||||
val existing = chessGameRecordRepository.findByGameId(session.gameId)
|
||||
if (existing != null) {
|
||||
require(existing.member.id == session.memberId) { "Chess game not found." }
|
||||
existing.apply(session)
|
||||
return
|
||||
}
|
||||
|
||||
val member = memberRepository.getReferenceById(session.memberId)
|
||||
chessGameRecordRepository.save(ChessGameRecord.from(member, session))
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? {
|
||||
return chessGameRecordRepository.findByGameIdAndMember_Id(gameId, memberId)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
|
||||
return chessGameRecordRepository.findAllByMember_Id(memberId, pageable)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun getStats(memberId: Long): ChessGameHistoryStats {
|
||||
return ChessGameHistoryStats(
|
||||
total = chessGameRecordRepository.countByMember_Id(memberId),
|
||||
inProgress = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.IN_PROGRESS),
|
||||
wins = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.WIN),
|
||||
losses = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.LOSS),
|
||||
draws = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.DRAW),
|
||||
unknown = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.UNKNOWN)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Index
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.persistence.UniqueConstraint
|
||||
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
||||
import me.wypark.blogbackend.domain.user.Member
|
||||
import java.time.ZoneId
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "chess_game_record",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(name = "uk_chess_game_record_game_id", columnNames = ["game_id"])
|
||||
],
|
||||
indexes = [
|
||||
Index(name = "idx_chess_game_record_member_updated_at", columnList = "member_id, updated_at"),
|
||||
Index(name = "idx_chess_game_record_member_outcome", columnList = "member_id, outcome")
|
||||
]
|
||||
)
|
||||
class ChessGameRecord(
|
||||
@Column(name = "game_id", nullable = false, length = 36)
|
||||
val gameId: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
val member: Member,
|
||||
|
||||
@Column(nullable = false)
|
||||
var rating: Int,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "player_color", nullable = false, length = 10)
|
||||
var playerColor: ChessSide,
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
var model: String,
|
||||
|
||||
@Column(nullable = false)
|
||||
var temperature: Double,
|
||||
|
||||
@Column(name = "top_p", nullable = false)
|
||||
var topP: Double,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var fen: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 10)
|
||||
var turn: ChessSide,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var moves: String,
|
||||
|
||||
@Column(nullable = false, length = 40)
|
||||
var status: String,
|
||||
|
||||
@Column(length = 16)
|
||||
var result: String?,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
var outcome: ChessGameOutcome,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var pgn: String
|
||||
) : BaseTimeEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null
|
||||
|
||||
fun apply(session: ChessGameSession) {
|
||||
rating = session.rating
|
||||
playerColor = session.playerColor
|
||||
model = session.model
|
||||
temperature = session.temperature
|
||||
topP = session.topP
|
||||
fen = session.fen
|
||||
turn = session.turn
|
||||
moves = session.moves.joinToString(" ")
|
||||
status = session.status
|
||||
result = session.result
|
||||
outcome = session.outcome()
|
||||
pgn = session.pgn
|
||||
}
|
||||
|
||||
fun moveList(): List<String> {
|
||||
return moves.split(" ").filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun toSession(): ChessGameSession {
|
||||
val zoneId = ZoneId.systemDefault()
|
||||
val memberId = requireNotNull(member.id) { "member id is required." }
|
||||
return ChessGameSession(
|
||||
gameId = gameId,
|
||||
memberId = memberId,
|
||||
rating = rating,
|
||||
playerColor = playerColor,
|
||||
model = model,
|
||||
temperature = temperature,
|
||||
topP = topP,
|
||||
fen = fen,
|
||||
turn = turn,
|
||||
moves = moveList(),
|
||||
status = status,
|
||||
result = result,
|
||||
pgn = pgn,
|
||||
createdAt = createdAt.atZone(zoneId).toInstant(),
|
||||
updatedAt = updatedAt.atZone(zoneId).toInstant()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(member: Member, session: ChessGameSession): ChessGameRecord {
|
||||
return ChessGameRecord(
|
||||
gameId = session.gameId,
|
||||
member = member,
|
||||
rating = session.rating,
|
||||
playerColor = session.playerColor,
|
||||
model = session.model,
|
||||
temperature = session.temperature,
|
||||
topP = session.topP,
|
||||
fen = session.fen,
|
||||
turn = session.turn,
|
||||
moves = session.moves.joinToString(" "),
|
||||
status = session.status,
|
||||
result = session.result,
|
||||
outcome = session.outcome(),
|
||||
pgn = session.pgn
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface ChessGameRecordRepository : JpaRepository<ChessGameRecord, Long> {
|
||||
|
||||
fun findByGameId(gameId: String): ChessGameRecord?
|
||||
|
||||
fun findByGameIdAndMember_Id(gameId: String, memberId: Long): ChessGameRecord?
|
||||
|
||||
fun findAllByMember_Id(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
|
||||
|
||||
fun countByMember_Id(memberId: Long): Long
|
||||
|
||||
fun countByMember_IdAndOutcome(memberId: Long, outcome: ChessGameOutcome): Long
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
|
||||
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessMoveRequest
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class ChessGameService(
|
||||
private val chessGameStore: ChessGameStore,
|
||||
private val chessGameHistoryStore: ChessGameHistoryStore,
|
||||
private val maiaEngine: MaiaEngine,
|
||||
private val clock: Clock
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun createGame(memberId: Long, request: ChessGameCreateRequest): ChessGameResponse {
|
||||
val playerColor = ChessSide.from(request.playerColor)
|
||||
val now = Instant.now(clock)
|
||||
val labels = playerLabels(playerColor, request.rating, request.model)
|
||||
|
||||
val initialState = maiaEngine.getState(
|
||||
MaiaStateRequest(
|
||||
moves = emptyList(),
|
||||
white = labels.white,
|
||||
black = labels.black
|
||||
)
|
||||
)
|
||||
var session = ChessGameSession(
|
||||
gameId = UUID.randomUUID().toString(),
|
||||
memberId = memberId,
|
||||
rating = request.rating,
|
||||
playerColor = playerColor,
|
||||
model = request.model,
|
||||
temperature = request.temperature,
|
||||
topP = request.topP,
|
||||
fen = initialState.fen,
|
||||
turn = ChessSide.from(initialState.turn),
|
||||
moves = emptyList(),
|
||||
status = initialState.status,
|
||||
result = initialState.result,
|
||||
pgn = initialState.pgn,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
|
||||
var maiaMove: String? = null
|
||||
if (playerColor == ChessSide.BLACK) {
|
||||
val maiaResponse = maiaEngine.playMove(session.toMaiaPlayRequest())
|
||||
maiaMove = maiaResponse.move
|
||||
session = session.applyEngineResponse(maiaResponse, now)
|
||||
}
|
||||
|
||||
saveSession(session)
|
||||
return ChessGameResponse.from(session, maiaMove)
|
||||
}
|
||||
|
||||
fun getGames(memberId: Long, pageable: Pageable): Page<ChessGameSummaryResponse> {
|
||||
return chessGameHistoryStore.findAllByMemberId(memberId, pageable)
|
||||
.map { ChessGameSummaryResponse.from(it) }
|
||||
}
|
||||
|
||||
fun getStats(memberId: Long): ChessGameStatsResponse {
|
||||
return ChessGameStatsResponse.from(chessGameHistoryStore.getStats(memberId))
|
||||
}
|
||||
|
||||
fun getGame(memberId: Long, gameId: String): ChessGameResponse {
|
||||
val session = chessGameStore.findById(gameId)
|
||||
if (session != null) {
|
||||
requireOwnedBy(session, memberId)
|
||||
return ChessGameResponse.from(session)
|
||||
}
|
||||
|
||||
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
|
||||
?: throw IllegalArgumentException("Chess game not found.")
|
||||
return ChessGameResponse.from(record)
|
||||
}
|
||||
|
||||
fun getPgn(memberId: Long, gameId: String): ChessGamePgnResponse {
|
||||
val game = getGame(memberId, gameId)
|
||||
return ChessGamePgnResponse(gameId = game.gameId, pgn = game.pgn)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun playMove(memberId: Long, gameId: String, request: ChessMoveRequest): ChessGameResponse {
|
||||
val session = getPlayableSession(memberId, gameId)
|
||||
require(session.status == "IN_PROGRESS") { "This chess game is already finished." }
|
||||
require(session.turn == session.playerColor) { "It is not the player's turn." }
|
||||
|
||||
val movesAfterPlayer = session.moves + request.move
|
||||
val maiaResponse = maiaEngine.playMove(
|
||||
session.toMaiaPlayRequest(moves = movesAfterPlayer)
|
||||
)
|
||||
|
||||
val updated = session.applyEngineResponse(maiaResponse, Instant.now(clock), movesAfterPlayer)
|
||||
saveSession(updated)
|
||||
return ChessGameResponse.from(updated, maiaResponse.move)
|
||||
}
|
||||
|
||||
private fun getPlayableSession(memberId: Long, gameId: String): ChessGameSession {
|
||||
val session = chessGameStore.findById(gameId)
|
||||
if (session != null) {
|
||||
requireOwnedBy(session, memberId)
|
||||
return session
|
||||
}
|
||||
|
||||
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
|
||||
?: throw IllegalArgumentException("Chess game not found.")
|
||||
return record.toSession()
|
||||
}
|
||||
|
||||
private fun saveSession(session: ChessGameSession) {
|
||||
chessGameStore.save(session)
|
||||
chessGameHistoryStore.save(session)
|
||||
}
|
||||
|
||||
private fun requireOwnedBy(session: ChessGameSession, memberId: Long) {
|
||||
require(session.memberId == memberId) { "Chess game not found." }
|
||||
}
|
||||
|
||||
private fun playerLabels(playerColor: ChessSide, rating: Int, model: String): PlayerLabels {
|
||||
val maiaName = "Maia3-$model-$rating"
|
||||
return if (playerColor == ChessSide.WHITE) {
|
||||
PlayerLabels(white = "Player", black = maiaName)
|
||||
} else {
|
||||
PlayerLabels(white = maiaName, black = "Player")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChessGameSession.toMaiaPlayRequest(
|
||||
moves: List<String> = this.moves
|
||||
): MaiaPlayRequest {
|
||||
val labels = playerLabels(playerColor, rating, model)
|
||||
return MaiaPlayRequest(
|
||||
moves = moves,
|
||||
rating = rating,
|
||||
model = model,
|
||||
temperature = temperature,
|
||||
topP = topP,
|
||||
white = labels.white,
|
||||
black = labels.black
|
||||
)
|
||||
}
|
||||
|
||||
private fun ChessGameSession.applyEngineResponse(
|
||||
response: MaiaPlayResponse,
|
||||
now: Instant,
|
||||
movesBeforeMaia: List<String> = moves
|
||||
): ChessGameSession {
|
||||
val nextMoves = response.move?.let { movesBeforeMaia + it } ?: movesBeforeMaia
|
||||
return copy(
|
||||
fen = response.fen,
|
||||
turn = ChessSide.from(response.turn),
|
||||
moves = nextMoves,
|
||||
status = response.status,
|
||||
result = response.result,
|
||||
pgn = response.pgn,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
|
||||
private data class PlayerLabels(
|
||||
val white: String,
|
||||
val black: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class ChessGameSession(
|
||||
val gameId: String,
|
||||
val memberId: Long,
|
||||
val rating: Int,
|
||||
val playerColor: ChessSide,
|
||||
val model: String,
|
||||
val temperature: Double,
|
||||
val topP: Double,
|
||||
val fen: String,
|
||||
val turn: ChessSide,
|
||||
val moves: List<String>,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val pgn: String,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant
|
||||
) {
|
||||
fun outcome(): ChessGameOutcome {
|
||||
return ChessGameOutcome.from(result = result, playerColor = playerColor, status = status)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChessSide(val value: String) {
|
||||
WHITE("white"),
|
||||
BLACK("black");
|
||||
|
||||
fun opposite(): ChessSide = if (this == WHITE) BLACK else WHITE
|
||||
|
||||
companion object {
|
||||
fun from(value: String): ChessSide {
|
||||
return entries.firstOrNull { it.value.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("playerColor must be white or black.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChessGameOutcome {
|
||||
IN_PROGRESS,
|
||||
WIN,
|
||||
LOSS,
|
||||
DRAW,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun from(result: String?, playerColor: ChessSide, status: String): ChessGameOutcome {
|
||||
if (status == "IN_PROGRESS") {
|
||||
return IN_PROGRESS
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
"1-0" -> if (playerColor == ChessSide.WHITE) WIN else LOSS
|
||||
"0-1" -> if (playerColor == ChessSide.BLACK) WIN else LOSS
|
||||
"1/2-1/2" -> DRAW
|
||||
null, "", "*" -> UNKNOWN
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
interface ChessGameStore {
|
||||
|
||||
fun save(session: ChessGameSession): ChessGameSession
|
||||
|
||||
fun findById(gameId: String): ChessGameSession?
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
interface MaiaEngine {
|
||||
|
||||
fun getState(request: MaiaStateRequest): MaiaStateResponse
|
||||
|
||||
fun playMove(request: MaiaPlayRequest): MaiaPlayResponse
|
||||
}
|
||||
|
||||
data class MaiaStateRequest(
|
||||
val moves: List<String>,
|
||||
val white: String? = null,
|
||||
val black: String? = null,
|
||||
val event: String = "Maia3"
|
||||
)
|
||||
|
||||
data class MaiaPlayRequest(
|
||||
val moves: List<String>,
|
||||
val rating: Int,
|
||||
val model: String,
|
||||
val temperature: Double,
|
||||
val topP: Double,
|
||||
val white: String? = null,
|
||||
val black: String? = null,
|
||||
val event: String = "Maia3"
|
||||
)
|
||||
|
||||
data class MaiaStateResponse(
|
||||
val fen: String,
|
||||
val turn: String,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val pgn: String = ""
|
||||
)
|
||||
|
||||
data class MaiaPlayResponse(
|
||||
val move: String?,
|
||||
val fen: String,
|
||||
val turn: String,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val pgn: String = ""
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
import org.springframework.web.client.RestClientResponseException
|
||||
|
||||
@Component
|
||||
class MaiaEngineClient(
|
||||
private val maiaRestClient: RestClient
|
||||
) : MaiaEngine {
|
||||
|
||||
private val log = LoggerFactory.getLogger(this.javaClass)
|
||||
|
||||
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
|
||||
return exchange("/maia/state", request, MaiaStateResponse::class.java)
|
||||
}
|
||||
|
||||
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
|
||||
return exchange("/maia/move", request, MaiaPlayResponse::class.java)
|
||||
}
|
||||
|
||||
private fun <T : Any> exchange(path: String, request: Any, responseType: Class<T>): T {
|
||||
try {
|
||||
return maiaRestClient
|
||||
.post()
|
||||
.uri(path)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(responseType)
|
||||
?: throw IllegalStateException("Maia 엔진 응답이 비어 있습니다.")
|
||||
} catch (e: RestClientResponseException) {
|
||||
log.warn("Maia engine returned {}: {}", e.statusCode, e.responseBodyAsString)
|
||||
throw IllegalArgumentException("Maia 엔진이 요청을 처리하지 못했습니다.")
|
||||
} catch (e: RestClientException) {
|
||||
log.error("Failed to call Maia engine", e)
|
||||
throw IllegalStateException("Maia 엔진에 연결할 수 없습니다.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import me.wypark.blogbackend.core.config.MaiaProperties
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Repository
|
||||
class RedisChessGameStore(
|
||||
private val redisTemplate: RedisTemplate<String, String>,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val maiaProperties: MaiaProperties
|
||||
) : ChessGameStore {
|
||||
|
||||
override fun save(session: ChessGameSession): ChessGameSession {
|
||||
redisTemplate.opsForValue().set(
|
||||
key(session.gameId),
|
||||
objectMapper.writeValueAsString(session),
|
||||
maiaProperties.gameSessionTtl.toMillis(),
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
override fun findById(gameId: String): ChessGameSession? {
|
||||
val json = redisTemplate.opsForValue().get(key(gameId)) ?: return null
|
||||
return objectMapper.readValue(json, ChessGameSession::class.java)
|
||||
}
|
||||
|
||||
private fun key(gameId: String): String = "CHESS_GAME:$gameId"
|
||||
}
|
||||
@@ -2,4 +2,8 @@ spring:
|
||||
application:
|
||||
name: blog-api
|
||||
profiles:
|
||||
default: prod
|
||||
default: prod
|
||||
|
||||
maia:
|
||||
engine-url: ${MAIA_ENGINE_URL:http://localhost:8000}
|
||||
game-session-ttl: ${MAIA_GAME_SESSION_TTL:PT6H}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE chess_game_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
game_id VARCHAR(36) NOT NULL,
|
||||
member_id BIGINT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
player_color VARCHAR(10) NOT NULL,
|
||||
model VARCHAR(10) NOT NULL,
|
||||
temperature DOUBLE PRECISION NOT NULL,
|
||||
top_p DOUBLE PRECISION NOT NULL,
|
||||
fen TEXT NOT NULL,
|
||||
turn VARCHAR(10) NOT NULL,
|
||||
moves TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(40) NOT NULL,
|
||||
result VARCHAR(16),
|
||||
outcome VARCHAR(20) NOT NULL,
|
||||
pgn TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_chess_game_record_game_id UNIQUE (game_id),
|
||||
CONSTRAINT fk_chess_game_record_member
|
||||
FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_chess_game_record_member_updated_at
|
||||
ON chess_game_record (member_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX idx_chess_game_record_member_outcome
|
||||
ON chess_game_record (member_id, outcome);
|
||||
@@ -0,0 +1,37 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ChessGameControllerSecurityIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@Test
|
||||
fun `chess game creation requires login`() {
|
||||
mockMvc.perform(
|
||||
post("/api/chess/games")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"rating":1500,"playerColor":"white","model":"5m"}""")
|
||||
)
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chess game history requires login`() {
|
||||
mockMvc.perform(get("/api/chess/games"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
|
||||
import me.wypark.blogbackend.api.dto.ChessMoveRequest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.Pageable
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class ChessGameServiceTest {
|
||||
|
||||
private lateinit var store: FakeChessGameStore
|
||||
private lateinit var historyStore: FakeChessGameHistoryStore
|
||||
private lateinit var engine: FakeMaiaEngine
|
||||
private lateinit var service: ChessGameService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
store = FakeChessGameStore()
|
||||
historyStore = FakeChessGameHistoryStore()
|
||||
engine = FakeMaiaEngine()
|
||||
service = ChessGameService(
|
||||
chessGameStore = store,
|
||||
chessGameHistoryStore = historyStore,
|
||||
maiaEngine = engine,
|
||||
clock = Clock.fixed(Instant.parse("2026-06-19T00:00:00Z"), ZoneOffset.UTC)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates white game without an immediate Maia move`() {
|
||||
val response = service.createGame(
|
||||
memberId = MEMBER_ID,
|
||||
request = ChessGameCreateRequest(rating = 1500, playerColor = "white", model = "5m")
|
||||
)
|
||||
|
||||
assertEquals(1500, response.rating)
|
||||
assertEquals("white", response.playerColor)
|
||||
assertEquals(emptyList(), response.moves)
|
||||
assertEquals(null, response.maiaMove)
|
||||
assertEquals("white", response.turn)
|
||||
assertEquals("IN_PROGRESS", response.status)
|
||||
assertEquals("IN_PROGRESS", response.outcome)
|
||||
assertEquals(MEMBER_ID, historyStore.savedSessions.single().memberId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates black game with Maia opening move`() {
|
||||
engine.playResponses.add(
|
||||
MaiaPlayResponse(
|
||||
move = "e2e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
|
||||
turn = "black",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 *"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.createGame(
|
||||
memberId = MEMBER_ID,
|
||||
request = ChessGameCreateRequest(rating = 1500, playerColor = "black", model = "5m")
|
||||
)
|
||||
|
||||
assertEquals(listOf("e2e4"), response.moves)
|
||||
assertEquals("e2e4", response.maiaMove)
|
||||
assertEquals("black", response.turn)
|
||||
assertEquals("1. e4 *", response.pgn)
|
||||
assertEquals(1, engine.playRequests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stores player move Maia reply PGN and outcome`() {
|
||||
val session = store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = emptyList(),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
engine.playResponses.add(
|
||||
MaiaPlayResponse(
|
||||
move = "c7c5",
|
||||
fen = "rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2",
|
||||
turn = "white",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 c5 *"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
|
||||
|
||||
assertEquals(listOf("e2e4", "c7c5"), response.moves)
|
||||
assertEquals("c7c5", response.maiaMove)
|
||||
assertEquals("white", response.turn)
|
||||
assertEquals("1. e4 c5 *", response.pgn)
|
||||
assertEquals("IN_PROGRESS", response.outcome)
|
||||
assertEquals(listOf("e2e4"), engine.playRequests.single().moves)
|
||||
assertEquals("1. e4 c5 *", historyStore.savedSessions.last().pgn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records a player win when result favors player color`() {
|
||||
val session = store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = emptyList(),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
engine.playResponses.add(
|
||||
MaiaPlayResponse(
|
||||
move = null,
|
||||
fen = "8/8/8/8/8/8/8/8 b - - 0 1",
|
||||
turn = "black",
|
||||
status = "CHECKMATE",
|
||||
result = "1-0",
|
||||
pgn = "1. e4 1-0"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
|
||||
|
||||
assertEquals("WIN", response.outcome)
|
||||
assertEquals("WIN", historyStore.savedSessions.last().outcome().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects move when it is not player's turn`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.BLACK,
|
||||
moves = listOf("e2e4"),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 *",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.playMove(MEMBER_ID, "game-1", ChessMoveRequest("d2d4"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects access to another member game`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = OTHER_MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = emptyList(),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.getGame(MEMBER_ID, "game-1")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEMBER_ID = 1L
|
||||
private const val OTHER_MEMBER_ID = 2L
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeChessGameStore : ChessGameStore {
|
||||
private val sessions = mutableMapOf<String, ChessGameSession>()
|
||||
|
||||
override fun save(session: ChessGameSession): ChessGameSession {
|
||||
sessions[session.gameId] = session
|
||||
return session
|
||||
}
|
||||
|
||||
override fun findById(gameId: String): ChessGameSession? = sessions[gameId]
|
||||
}
|
||||
|
||||
private class FakeChessGameHistoryStore : ChessGameHistoryStore {
|
||||
val savedSessions = mutableListOf<ChessGameSession>()
|
||||
|
||||
override fun save(session: ChessGameSession) {
|
||||
savedSessions.add(session)
|
||||
}
|
||||
|
||||
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? = null
|
||||
|
||||
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
|
||||
return PageImpl(emptyList(), pageable, 0)
|
||||
}
|
||||
|
||||
override fun getStats(memberId: Long): ChessGameHistoryStats {
|
||||
return ChessGameHistoryStats(
|
||||
total = 0,
|
||||
inProgress = 0,
|
||||
wins = 0,
|
||||
losses = 0,
|
||||
draws = 0,
|
||||
unknown = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeMaiaEngine : MaiaEngine {
|
||||
val playResponses = ArrayDeque<MaiaPlayResponse>()
|
||||
val playRequests = mutableListOf<MaiaPlayRequest>()
|
||||
|
||||
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
|
||||
return MaiaStateResponse(
|
||||
fen = START_FEN,
|
||||
turn = "white",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*"
|
||||
)
|
||||
}
|
||||
|
||||
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
|
||||
playRequests.add(request)
|
||||
return playResponses.removeFirst()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user