diff --git a/.runConfigurations/Format.run.xml b/.runConfigurations/Format.run.xml index 84d7259f..f043e364 100644 --- a/.runConfigurations/Format.run.xml +++ b/.runConfigurations/Format.run.xml @@ -20,4 +20,4 @@ false - \ No newline at end of file + diff --git a/docs/spec/CodeCharacter-API.yml b/docs/spec/CodeCharacter-API.yml index ed6e545e..8facc446 100644 --- a/docs/spec/CodeCharacter-API.yml +++ b/docs/spec/CodeCharacter-API.yml @@ -303,6 +303,7 @@ paths: description: Leaderboard Tier description: Get leaderboard parameters: [] + /top-matches: get: summary: Get top matches @@ -1934,6 +1935,7 @@ components: - user - stats description: Leaderboard entry model + DailyChallengeGetRequest: title: Get daily challenge description: Get current-user daily challenge diff --git a/library/src/main/kotlin/delta/codecharacter/core/DailyChallengesApi.kt b/library/src/main/kotlin/delta/codecharacter/core/DailyChallengesApi.kt index 2950dd4e..4f3303da 100644 --- a/library/src/main/kotlin/delta/codecharacter/core/DailyChallengesApi.kt +++ b/library/src/main/kotlin/delta/codecharacter/core/DailyChallengesApi.kt @@ -15,19 +15,24 @@ import io.swagger.v3.oas.annotations.media.* import io.swagger.v3.oas.annotations.responses.* import io.swagger.v3.oas.annotations.security.* import org.springframework.http.HttpStatus -import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import org.springframework.validation.annotation.Validated -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.beans.factory.annotation.Autowired import jakarta.validation.constraints.* import jakarta.validation.Valid +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import java.util.Date import kotlin.collections.List -import kotlin.collections.Map @Validated @RequestMapping("\${api.base-path:}") diff --git a/server/src/main/kotlin/delta/codecharacter/server/match/AutoMatchEntity.kt b/server/src/main/kotlin/delta/codecharacter/server/match/AutoMatchEntity.kt new file mode 100644 index 00000000..9cfd4e4c --- /dev/null +++ b/server/src/main/kotlin/delta/codecharacter/server/match/AutoMatchEntity.kt @@ -0,0 +1,8 @@ +package delta.codecharacter.server.match + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.util.UUID + +@Document(collection = "auto_match") +data class AutoMatchEntity(@Id val matchId: UUID, val tries: Int) diff --git a/server/src/main/kotlin/delta/codecharacter/server/match/AutoMatchRepository.kt b/server/src/main/kotlin/delta/codecharacter/server/match/AutoMatchRepository.kt new file mode 100644 index 00000000..6637962f --- /dev/null +++ b/server/src/main/kotlin/delta/codecharacter/server/match/AutoMatchRepository.kt @@ -0,0 +1,7 @@ +package delta.codecharacter.server.match + +import org.springframework.data.mongodb.repository.MongoRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository interface AutoMatchRepository : MongoRepository diff --git a/server/src/main/kotlin/delta/codecharacter/server/match/MatchRepository.kt b/server/src/main/kotlin/delta/codecharacter/server/match/MatchRepository.kt index 124ba5b8..a5d94a55 100644 --- a/server/src/main/kotlin/delta/codecharacter/server/match/MatchRepository.kt +++ b/server/src/main/kotlin/delta/codecharacter/server/match/MatchRepository.kt @@ -9,4 +9,5 @@ import java.util.UUID interface MatchRepository : MongoRepository { fun findTop10ByOrderByTotalPointsDesc(): List fun findByPlayer1OrderByCreatedAtDesc(player1: PublicUserEntity): List + fun findByIdIn(matchIds: List): List } diff --git a/server/src/main/kotlin/delta/codecharacter/server/match/MatchService.kt b/server/src/main/kotlin/delta/codecharacter/server/match/MatchService.kt index 7a2949ba..fe604707 100644 --- a/server/src/main/kotlin/delta/codecharacter/server/match/MatchService.kt +++ b/server/src/main/kotlin/delta/codecharacter/server/match/MatchService.kt @@ -30,6 +30,8 @@ import delta.codecharacter.server.logic.verdict.VerdictAlgorithm import delta.codecharacter.server.notifications.NotificationService import delta.codecharacter.server.user.public_user.PublicUserService import delta.codecharacter.server.user.rating_history.RatingHistoryService +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.springframework.amqp.rabbit.annotation.RabbitListener import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus @@ -59,9 +61,11 @@ class MatchService( @Autowired private val dailyChallengeMatchRepository: DailyChallengeMatchRepository, @Autowired private val jackson2ObjectMapperBuilder: Jackson2ObjectMapperBuilder, @Autowired private val simpMessagingTemplate: SimpMessagingTemplate, - @Autowired private val mapValidator: MapValidator + @Autowired private val mapValidator: MapValidator, + @Autowired private val autoMatchRepository: AutoMatchRepository ) { private var mapper: ObjectMapper = jackson2ObjectMapperBuilder.build() + private val logger: Logger = LoggerFactory.getLogger(MatchService::class.java) private fun createSelfMatch(userId: UUID, codeRevisionId: UUID?, mapRevisionId: UUID?) { val code: String @@ -107,19 +111,13 @@ class MatchService( gameService.sendGameRequest(game, code, LanguageEnum.valueOf(language.name), map) } - fun createDualMatch(userId: UUID, opponentUsername: String) { + fun createDualMatch(userId: UUID, opponentUsername: String, mode: MatchModeEnum): UUID { val publicUser = publicUserService.getPublicUser(userId) val publicOpponent = publicUserService.getPublicUserByUsername(opponentUsername) val opponentId = publicOpponent.userId - if (userId == opponentId) { throw CustomException(HttpStatus.BAD_REQUEST, "You cannot play against yourself") } - if (publicOpponent.tier == TierTypeDto.TIER1) { - throw CustomException( - HttpStatus.BAD_REQUEST, "Opponent cannot be a tier 1 player in manual match" - ) - } val (userLanguage, userCode) = lockedCodeService.getLockedCode(userId) val userMap = lockedMapService.getLockedMap(userId) @@ -135,7 +133,7 @@ class MatchService( MatchEntity( id = matchId, games = listOf(game1, game2), - mode = MatchModeEnum.MANUAL, + mode = mode, verdict = MatchVerdictEnum.TIE, createdAt = Instant.now(), totalPoints = 0, @@ -146,6 +144,12 @@ class MatchService( gameService.sendGameRequest(game1, userCode, userLanguage, opponentMap) gameService.sendGameRequest(game2, opponentCode, opponentLanguage, userMap) + if (mode == MatchModeEnum.AUTO) { + logger.info( + "Auto match started between ${match.player1.username} and ${match.player2.username}" + ) + } + return matchId } fun createDCMatch(userId: UUID, dailyChallengeMatchRequestDto: DailyChallengeMatchRequestDto) { @@ -198,7 +202,7 @@ class MatchService( if (createMatchRequestDto.opponentUsername == null) { throw CustomException(HttpStatus.BAD_REQUEST, "Opponent ID is required") } - createDualMatch(userId, createMatchRequestDto.opponentUsername!!) + createDualMatch(userId, createMatchRequestDto.opponentUsername!!, MatchModeEnum.MANUAL) } else -> { throw CustomException(HttpStatus.BAD_REQUEST, "MatchMode Is Not Correct") @@ -206,6 +210,23 @@ class MatchService( } } + fun createAutoMatch() { + val topNUsers = publicUserService.getTopNUsers() + val userIds = topNUsers.map { it.userId } + val usernames = topNUsers.map { it.username } + logger.info("Auto matches started for users: $usernames") + autoMatchRepository.deleteAll() + userIds.forEachIndexed { i, userId -> + run { + for (j in i + 1 until userIds.size) { + val opponentUsername = usernames[j] + val matchId = createDualMatch(userId, opponentUsername, MatchModeEnum.AUTO) + autoMatchRepository.save(AutoMatchEntity(matchId, 0)) + } + } + } + } + private fun mapMatchEntitiesToDtos(matchEntities: List): List { return matchEntities.map { matchEntity -> MatchDto( @@ -285,7 +306,10 @@ class MatchService( fun getUserMatches(userId: UUID): List { val publicUser = publicUserService.getPublicUser(userId) - val matches = matchRepository.findByPlayer1OrderByCreatedAtDesc(publicUser) + val matches = + matchRepository.findByPlayer1OrderByCreatedAtDesc(publicUser).filter { match -> + match.mode != MatchModeEnum.AUTO + } val dcMatches = dailyChallengeMatchRepository.findByUserOrderByCreatedAtDesc(publicUser).takeWhile { Duration.between(it.createdAt, Instant.now()).toHours() < 24 && @@ -318,6 +342,20 @@ class MatchService( game.status == GameStatusEnum.EXECUTED || game.status == GameStatusEnum.EXECUTE_ERROR } ) { + + if (match.mode == MatchModeEnum.AUTO) { + if (match.games.any { game -> game.status == GameStatusEnum.EXECUTE_ERROR }) { + val autoMatch = autoMatchRepository.findById(match.id).get() + if (autoMatch.tries < 2) { + autoMatchRepository.delete(autoMatch) + val newMatchId = + createDualMatch(match.player1.userId, match.player2.username, MatchModeEnum.AUTO) + autoMatchRepository.save(AutoMatchEntity(newMatchId, autoMatch.tries + 1)) + return + } + } + } + val player1Game = match.games.first() val player2Game = match.games.last() val verdict = @@ -332,22 +370,21 @@ class MatchService( val finishedMatch = match.copy(verdict = verdict) val (newUserRating, newOpponentRating) = ratingHistoryService.updateRating(match.player1.userId, match.player2.userId, verdict) - if (!(match.mode == MatchModeEnum.MANUAL && (match.player1.tier == TierTypeDto.TIER1))) { - - publicUserService.updatePublicRating( - userId = match.player1.userId, - isInitiator = true, - verdict = verdict, - newRating = newUserRating - ) - publicUserService.updatePublicRating( - userId = match.player2.userId, - isInitiator = false, - verdict = verdict, - newRating = newOpponentRating - ) - } if (match.mode == MatchModeEnum.MANUAL) { + if (match.player1.tier == TierTypeDto.TIER2 && match.player2.tier == TierTypeDto.TIER2) { + publicUserService.updatePublicRating( + userId = match.player1.userId, + isInitiator = true, + verdict = verdict, + newRating = newUserRating + ) + publicUserService.updatePublicRating( + userId = match.player2.userId, + isInitiator = false, + verdict = verdict, + newRating = newOpponentRating + ) + } notificationService.sendNotification( match.player1.userId, "Match Result", @@ -360,8 +397,37 @@ class MatchService( } against ${match.player2.username}", ) } - matchRepository.save(finishedMatch) + + if (match.mode == MatchModeEnum.AUTO) { + if (autoMatchRepository.findAll().all { autoMatch -> + matchRepository.findById(autoMatch.matchId).get().games.all { game -> + game.status == GameStatusEnum.EXECUTED || game.status == GameStatusEnum.EXECUTE_ERROR + } + } + ) { + val matches = + matchRepository.findByIdIn(autoMatchRepository.findAll().map { it.matchId }) + val userIds = + matches.map { it.player1.userId }.toSet() + + matches.map { it.player2.userId }.toSet() + val newRatings = + ratingHistoryService.updateAndGetAutoMatchRatings(userIds.toList(), matches) + newRatings.forEach { (userId, newRating) -> + publicUserService.updatePublicRating( + userId = userId, + isInitiator = true, + verdict = verdict, + newRating = newRating.rating + ) + } + logger.info("LeaderBoard Tier Promotion and Demotion started") + publicUserService.promoteTiers() + } + logger.info( + "Match between ${match.player1.username} and ${match.player2.username} completed with verdict $verdict" + ) + } } } else if (dailyChallengeMatchRepository.findById(matchId).isPresent) { val match = dailyChallengeMatchRepository.findById(matchId).get() diff --git a/server/src/main/kotlin/delta/codecharacter/server/schedulers/SchedulingService.kt b/server/src/main/kotlin/delta/codecharacter/server/schedulers/SchedulingService.kt index 8d6c437d..6900bba2 100644 --- a/server/src/main/kotlin/delta/codecharacter/server/schedulers/SchedulingService.kt +++ b/server/src/main/kotlin/delta/codecharacter/server/schedulers/SchedulingService.kt @@ -1,5 +1,6 @@ package delta.codecharacter.server.schedulers +import delta.codecharacter.server.match.MatchService import delta.codecharacter.server.user.public_user.PublicUserService import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -8,7 +9,10 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service @Service -class SchedulingService(@Autowired private val publicUserService: PublicUserService) { +class SchedulingService( + @Autowired private val publicUserService: PublicUserService, + @Autowired private val matchService: MatchService +) { private val logger: Logger = LoggerFactory.getLogger(SchedulingService::class.java) @Scheduled(cron = "\${environment.registration-time}", zone = "GMT+5:30") @@ -17,9 +21,9 @@ class SchedulingService(@Autowired private val publicUserService: PublicUserServ publicUserService.resetRatingsAfterPracticePhase() publicUserService.updateLeaderboardAfterPracticePhase() } - @Scheduled(cron = "\${environment.update-time}", zone = "GMT+5:30") - fun promoteAndDemoteUserTiers() { - logger.info("LeaderBoard Tier Promotion and Demotion started") - publicUserService.promoteTiers() + + @Scheduled(cron = "\${environment.promote-demote-time}", zone = "GMT+5:30") + fun createAutoMatch() { + matchService.createAutoMatch() } } diff --git a/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserRepository.kt b/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserRepository.kt index 09883522..8a6d96e9 100644 --- a/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserRepository.kt +++ b/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserRepository.kt @@ -9,5 +9,7 @@ import java.util.UUID interface PublicUserRepository : MongoRepository { fun findByUsername(username: String): Optional + fun findTopnByOrderByRatingDesc(pageRequest: PageRequest): List + fun findAllByTier(tier: TierTypeDto?, pageRequest: PageRequest): List } diff --git a/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserService.kt b/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserService.kt index e2e4eec6..c1b19fdb 100644 --- a/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserService.kt +++ b/server/src/main/kotlin/delta/codecharacter/server/user/public_user/PublicUserService.kt @@ -282,4 +282,9 @@ class PublicUserService(@Autowired private val publicUserRepository: PublicUserR val updatedUser = user.copy(score = user.score + score, dailyChallengeHistory = current) publicUserRepository.save(updatedUser) } + + fun getTopNUsers(): List { + val pageRequest = PageRequest.of(0, tier1Players.toInt(), Sort.by("rating")) + return publicUserRepository.findTopnByOrderByRatingDesc(pageRequest) + } } diff --git a/server/src/main/kotlin/delta/codecharacter/server/user/rating_history/RatingHistoryService.kt b/server/src/main/kotlin/delta/codecharacter/server/user/rating_history/RatingHistoryService.kt index a838d5f7..7de0ada7 100644 --- a/server/src/main/kotlin/delta/codecharacter/server/user/rating_history/RatingHistoryService.kt +++ b/server/src/main/kotlin/delta/codecharacter/server/user/rating_history/RatingHistoryService.kt @@ -3,6 +3,7 @@ package delta.codecharacter.server.user.rating_history import delta.codecharacter.dtos.RatingHistoryDto import delta.codecharacter.server.logic.rating.GlickoRating import delta.codecharacter.server.logic.rating.RatingAlgorithm +import delta.codecharacter.server.match.MatchEntity import delta.codecharacter.server.match.MatchVerdictEnum import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service @@ -93,4 +94,73 @@ class RatingHistoryService( return Pair(newUserRating.rating, newOpponentRating.rating) } + + private fun getNewRatingAfterAutoMatches( + userId: UUID, + userRatings: Map, + autoMatches: List + ): GlickoRating { + val userAsInitiatorMatches = autoMatches.filter { it.player1.userId == userId } + val userAsOpponentMatches = autoMatches.filter { it.player2.userId == userId } + + val usersWeightedRatingDeviations = + userRatings + .map { + it.key to + ratingAlgorithm.getWeightedRatingDeviationSinceLastCompetition( + it.value.ratingDeviation, it.value.validFrom + ) + } + .toMap() + + val ratingsForUserAsPlayer1 = + userAsInitiatorMatches.map { match -> + GlickoRating(match.player2.rating, usersWeightedRatingDeviations[match.player2.userId]!!) + } + val verdictsForUserAsPlayer1 = + userAsInitiatorMatches.map { match -> convertVerdictToMatchResult(match.verdict) } + + val ratingsForUserAsPlayer2 = + userAsOpponentMatches.map { match -> + GlickoRating(match.player1.rating, usersWeightedRatingDeviations[match.player1.userId]!!) + } + val verdictsForUserAsPlayer2 = + userAsOpponentMatches.map { match -> 1.0 - convertVerdictToMatchResult(match.verdict) } + + val ratings = ratingsForUserAsPlayer1 + ratingsForUserAsPlayer2 + val verdicts = verdictsForUserAsPlayer1 + verdictsForUserAsPlayer2 + + return ratingAlgorithm.calculateNewRating( + GlickoRating(userRatings[userId]!!.rating, usersWeightedRatingDeviations[userId]!!), + ratings, + verdicts + ) + } + + fun updateAndGetAutoMatchRatings( + userIds: List, + matches: List + ): Map { + val userRatings = + userIds.associateWith { userId -> + ratingHistoryRepository.findFirstByUserIdOrderByValidFromDesc(userId) + } + val newRatings = + userIds.associateWith { userId -> + getNewRatingAfterAutoMatches(userId, userRatings, matches) + } + val currentInstant = Instant.now() + newRatings.forEach { (userId, rating) -> + ratingHistoryRepository.save( + RatingHistoryEntity( + userId = userId, + rating = rating.rating, + ratingDeviation = rating.ratingDeviation, + validFrom = currentInstant + ) + ) + } + + return newRatings + } } diff --git a/server/src/main/resources/application.example.yml b/server/src/main/resources/application.example.yml index 81cc765f..233c146c 100644 --- a/server/src/main/resources/application.example.yml +++ b/server/src/main/resources/application.example.yml @@ -31,7 +31,7 @@ environment: registration-time: "* * * * * *" no-of-tier-1-players: 20 no-of-players-for-promotion: 5 - update-time: "*/20 * * * * *" + promote-demote-time: "* * */6 * * *" server: compression: diff --git a/server/src/test/kotlin/delta/codecharacter/server/match/MatchServiceTest.kt b/server/src/test/kotlin/delta/codecharacter/server/match/MatchServiceTest.kt index b5c0571f..b57d784c 100644 --- a/server/src/test/kotlin/delta/codecharacter/server/match/MatchServiceTest.kt +++ b/server/src/test/kotlin/delta/codecharacter/server/match/MatchServiceTest.kt @@ -8,7 +8,6 @@ import delta.codecharacter.dtos.DailyChallengeMatchRequestDto import delta.codecharacter.dtos.GameMapRevisionDto import delta.codecharacter.dtos.LanguageDto import delta.codecharacter.dtos.MatchModeDto -import delta.codecharacter.dtos.TierTypeDto import delta.codecharacter.server.TestAttributes import delta.codecharacter.server.code.LanguageEnum import delta.codecharacter.server.code.code_revision.CodeRevisionService @@ -61,6 +60,7 @@ internal class MatchServiceTest { private lateinit var simpMessagingTemplate: SimpMessagingTemplate private lateinit var mapValidator: MapValidator private lateinit var matchService: MatchService + private lateinit var autoMatchRepository: AutoMatchRepository @BeforeEach fun setUp() { @@ -81,6 +81,7 @@ internal class MatchServiceTest { jackson2ObjectMapperBuilder = Jackson2ObjectMapperBuilder() simpMessagingTemplate = mockk(relaxed = true) mapValidator = mockk(relaxed = true) + autoMatchRepository = mockk(relaxed = true) matchService = MatchService( @@ -100,7 +101,8 @@ internal class MatchServiceTest { dailyChallengeMatchRepository, jackson2ObjectMapperBuilder, simpMessagingTemplate, - mapValidator + mapValidator, + autoMatchRepository ) } @@ -281,42 +283,6 @@ internal class MatchServiceTest { ) } - @Test - @Throws(CustomException::class) - fun `should throw bad request if the opponent player belongs to tier 1 in manual match`() { - val playerId = UUID.randomUUID() - val opponentId = UUID.randomUUID() - val opponentPublicUser = - TestAttributes.publicUser.copy( - userId = opponentId, username = "opponent", tier = TierTypeDto.TIER1 - ) - val userCode = Pair(LanguageEnum.CPP, "user-code") - val opponentCode = Pair(LanguageEnum.PYTHON, "opponent-code") - val userMap = "user-map" - val opponentMap = "opponent-map" - every { publicUserService.getPublicUserByUsername(opponentPublicUser.username) } returns - opponentPublicUser - every { lockedCodeService.getLockedCode(playerId) } returns userCode - every { lockedCodeService.getLockedCode(opponentId) } returns opponentCode - every { lockedMapService.getLockedMap(playerId) } returns userMap - every { lockedMapService.getLockedMap(opponentId) } returns opponentMap - every { gameService.createGame(any()) } returns mockk() - every { matchRepository.save(any()) } returns mockk() - every { gameService.sendGameRequest(any(), userCode.second, userCode.first, userMap) } returns - Unit - every { - gameService.sendGameRequest(any(), opponentCode.second, opponentCode.first, opponentMap) - } returns Unit - - val exception = - assertThrows { - matchService.createDualMatch(playerId, opponentPublicUser.username) - } - - assertThat(exception.status).isEqualTo(HttpStatus.BAD_REQUEST) - assertThat(exception.message).isEqualTo("Opponent cannot be a tier 1 player in manual match") - } - @Test fun `should create auto match`() { val userId = UUID.randomUUID()