diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchStatus.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchStatus.java new file mode 100644 index 0000000..6d15d80 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchStatus.java @@ -0,0 +1,6 @@ +package com.codzilla.backend.Matchmaking; + +public enum MatchStatus { + WAITING, + NOT_IN_QUEUE +} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchStatusResult.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchStatusResult.java new file mode 100644 index 0000000..4b2d8f5 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchStatusResult.java @@ -0,0 +1,7 @@ +package com.codzilla.backend.Matchmaking; + +public record MatchStatusResult( + MatchStatus status, + int queueSize, + long waitingSeconds +) {} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java new file mode 100644 index 0000000..8951430 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java @@ -0,0 +1,52 @@ +package com.codzilla.backend.Matchmaking; + +import com.codzilla.backend.Matchmaking.dto.MatchStatusDTO; +import com.codzilla.backend.User.User; +import com.codzilla.backend.User.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/matchmaking") +public class MatchmakingController { + + private final MatchmakingService matchmakingService; + private final UserRepository userRepository; + + public MatchmakingController(MatchmakingService matchmakingService, + UserRepository userRepository) { + this.matchmakingService = matchmakingService; + this.userRepository = userRepository; + } + + private UUID resolveUserId(User principal) { + return userRepository.findIdByEmail(principal.getEmail()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } + + @PostMapping("/queue") + public ResponseEntity enterQueue(@AuthenticationPrincipal User user) { + matchmakingService.enterQueue(resolveUserId(user)); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/queue") + public ResponseEntity leaveQueue(@AuthenticationPrincipal User user) { + matchmakingService.leaveQueue(resolveUserId(user)); + return ResponseEntity.ok().build(); + } + + @GetMapping("/queue/status") + public ResponseEntity queueStatus(@AuthenticationPrincipal User user) { + MatchStatusResult result = matchmakingService.queueStatus(resolveUserId(user)); + return ResponseEntity.ok(new MatchStatusDTO( + result.status(), + result.queueSize(), + result.waitingSeconds() + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingScheduler.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingScheduler.java new file mode 100644 index 0000000..48412eb --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingScheduler.java @@ -0,0 +1,21 @@ +package com.codzilla.backend.Matchmaking; + +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@EnableScheduling +public class MatchmakingScheduler { + + private final MatchmakingService matchmakingService; + + public MatchmakingScheduler(MatchmakingService matchmakingService) { + this.matchmakingService = matchmakingService; + } + + @Scheduled(fixedDelay = 2000) + public void runMatchmaking() { + matchmakingService.runMatchmaking(); + } +} diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java new file mode 100644 index 0000000..b499913 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java @@ -0,0 +1,92 @@ +package com.codzilla.backend.Matchmaking; + +import com.codzilla.backend.Authentication.Exceptions.UserNotFoundException; +import com.codzilla.backend.User.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class MatchmakingService { + + public static final int BASE_WINDOW = 50; + public static final int WAVE_STEP = 25; + public static final int MAX_WINDOW = 400; + + private final ConcurrentHashMap queue = new ConcurrentHashMap<>(); + private final UserRepository userRepository; + + public MatchmakingService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public void enterQueue(UUID userId) { + var user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + queue.put(userId, new QueueEntry(userId, user.getRating(), Instant.now())); + log.info("[MM] User {} (rating={}) entered queue. Size: {}", userId, user.getRating(), queue.size()); + } + + public void leaveQueue(UUID userId) { + queue.remove(userId); + log.info("[MM] User {} left queue. Size: {}", userId, queue.size()); + } + + public MatchStatusResult queueStatus(UUID userId) { + QueueEntry entry = queue.get(userId); + if (entry == null) { + return new MatchStatusResult(MatchStatus.NOT_IN_QUEUE, queue.size(), 0); + } + long waiting = Instant.now().getEpochSecond() - entry.joinedAt().getEpochSecond(); + return new MatchStatusResult(MatchStatus.WAITING, queue.size(), waiting); + } + + public synchronized void runMatchmaking() { + if (queue.size() < 2) return; + + List sorted = new ArrayList<>(queue.values()); + sorted.sort(Comparator.comparingInt(QueueEntry::rating)); + + Set matched = new HashSet<>(); + + for (int i = 0; i < sorted.size() - 1; i++) { + QueueEntry a = sorted.get(i); + if (matched.contains(a.userId())) continue; + + for (int j = i + 1; j < sorted.size(); j++) { + QueueEntry b = sorted.get(j); + if (matched.contains(b.userId())) continue; + + int diff = Math.abs(a.rating() - b.rating()); + int window = Math.min(a.ratingWindow(), b.ratingWindow()); + + if (diff <= window) { + pair(a.userId(), b.userId()); + matched.add(a.userId()); + matched.add(b.userId()); + break; + } + + if (diff > MAX_WINDOW) break; + } + } + } + + private void pair(UUID a, UUID b) { + QueueEntry removedA = queue.remove(a); + QueueEntry removedB = queue.remove(b); + + if (removedA == null || removedB == null) { + if (removedA != null) queue.put(a, removedA); + if (removedB != null) queue.put(b, removedB); + log.warn("[MM] Pairing cancelled: one of users left queue ({} or {})", a, b); + return; + } + // TODO implement create session call + log.info("[MM] Paired: {} vs {}", a, b); + } +} \ No newline at end of file diff --git a/src/main/java/com/codzilla/backend/Matchmaking/QueueEntry.java b/src/main/java/com/codzilla/backend/Matchmaking/QueueEntry.java new file mode 100644 index 0000000..567f5d4 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/QueueEntry.java @@ -0,0 +1,16 @@ +package com.codzilla.backend.Matchmaking; + +import java.time.Instant; +import java.util.UUID; + +public record QueueEntry( + UUID userId, + int rating, + Instant joinedAt +) { + public int ratingWindow() { + long secondsWaiting = Instant.now().getEpochSecond() - joinedAt.getEpochSecond(); + int wave = (int) (secondsWaiting / 5) * MatchmakingService.WAVE_STEP; + return Math.min(wave + MatchmakingService.BASE_WINDOW, MatchmakingService.MAX_WINDOW); + } +} diff --git a/src/main/java/com/codzilla/backend/Matchmaking/dto/MatchStatusDTO.java b/src/main/java/com/codzilla/backend/Matchmaking/dto/MatchStatusDTO.java new file mode 100644 index 0000000..1eb2f00 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/dto/MatchStatusDTO.java @@ -0,0 +1,9 @@ +package com.codzilla.backend.Matchmaking.dto; + +import com.codzilla.backend.Matchmaking.MatchStatus; + +public record MatchStatusDTO( + MatchStatus status, + int queueSize, + long waitingSeconds +) {} \ No newline at end of file diff --git a/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java b/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java new file mode 100644 index 0000000..9ffb7ed --- /dev/null +++ b/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java @@ -0,0 +1,153 @@ +package com.codzilla.backend.Matchmaking; + +import com.codzilla.backend.User.User; +import com.codzilla.backend.User.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchmakingServiceTest { + + @Mock + private UserRepository userRepository; + + private MatchmakingService service; + + private ConcurrentHashMap queue; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + service = new MatchmakingService(userRepository); + + Field queueField = MatchmakingService.class.getDeclaredField("queue"); + queueField.setAccessible(true); + queue = (ConcurrentHashMap) queueField.get(service); + } + + private QueueEntry entry(UUID userId, int rating, long waitingSeconds) { + return new QueueEntry(userId, rating, Instant.now().minusSeconds(waitingSeconds)); + } + + private UUID addToQueue(int rating, long waitingSeconds) { + UUID id = UUID.randomUUID(); + queue.put(id, entry(id, rating, waitingSeconds)); + return id; + } + + private UUID addToQueue(int rating) { + return addToQueue(rating, 0); + } + + private UUID mockUser(int rating) { + UUID id = UUID.randomUUID(); + User user = User.builder().id(id).rating(rating).email(id + "@test.com").build(); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + return id; + } + + @Test + @DisplayName("enterQueue: игрок добавляется в очередь с правильным рейтингом") + void enterQueue_addsUserWithCorrectRating() { + UUID userId = mockUser(1200); + + service.enterQueue(userId); + + assertThat(queue).containsKey(userId); + assertThat(queue.get(userId).rating()).isEqualTo(1200); + } + + @Test + @DisplayName("enterQueue: повторный вызов обновляет запись (рейтинг мог измениться)") + void enterQueue_updatesExistingEntry() { + UUID userId = mockUser(1200); + service.enterQueue(userId); + + User updated = User.builder().id(userId).rating(1300).email(userId + "@test.com").build(); + when(userRepository.findById(userId)).thenReturn(Optional.of(updated)); + service.enterQueue(userId); + + assertThat(queue.get(userId).rating()).isEqualTo(1300); + } + + @Test + @DisplayName("leaveQueue: игрок убирается из очереди") + void leaveQueue_removesUser() { + UUID userId = addToQueue(1000); + + service.leaveQueue(userId); + + assertThat(queue).doesNotContainKey(userId); + } + + @Test + @DisplayName("leaveQueue: вызов для игрока не в очереди не бросает исключение") + void leaveQueue_nonExistentUser_doesNotThrow() { + assertThatNoException().isThrownBy(() -> service.leaveQueue(UUID.randomUUID())); + } + + @Test + @DisplayName("runMatchmaking: игроки с разницей рейтинга больше окна НЕ спариваются") + void runMatchmaking_doesNotPairPlayersOutsideWindow() { + UUID a = addToQueue(1000); + UUID b = addToQueue(1100); + + service.runMatchmaking(); + + assertThat(queue).containsKey(a).containsKey(b); + } + + @Test + @DisplayName("runMatchmaking: меньше двух игроков — ничего не происходит") + void runMatchmaking_lessThanTwoPlayers_doesNothing() { + addToQueue(1000); + + service.runMatchmaking(); + + assertThat(queue).hasSize(1); + } + + @Test + @DisplayName("runMatchmaking: окно не превышает MAX_WINDOW даже при долгом ожидании") + void runMatchmaking_windowCappedAtMaxWindow() { + UUID a = addToQueue(1000, 10000); + UUID b = addToQueue(1500); + + service.runMatchmaking(); + + assertThat(queue).containsKey(a).containsKey(b); + } + + @Test + @DisplayName("queueStatus: игрок в очереди — статус WAITING") + void queueStatus_playerInQueue_returnsWaiting() { + UUID userId = addToQueue(1000, 5); + + var status = service.queueStatus(userId); + + assertThat(status.status()).isEqualTo(MatchStatus.WAITING); + assertThat(status.queueSize()).isEqualTo(1); + assertThat(status.waitingSeconds()).isGreaterThanOrEqualTo(5); + } + + @Test + @DisplayName("queueStatus: игрок не в очереди — статус NOT_IN_QUEUE") + void queueStatus_playerNotInQueue_returnsNotInQueue() { + var status = service.queueStatus(UUID.randomUUID()); + + assertThat(status.status()).isEqualTo(MatchStatus.NOT_IN_QUEUE); + } +} \ No newline at end of file diff --git a/src/test/java/com/codzilla/backend/Matchmaking/QueueEntryTest.java b/src/test/java/com/codzilla/backend/Matchmaking/QueueEntryTest.java new file mode 100644 index 0000000..a412c87 --- /dev/null +++ b/src/test/java/com/codzilla/backend/Matchmaking/QueueEntryTest.java @@ -0,0 +1,62 @@ +package com.codzilla.backend.Matchmaking; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class QueueEntryTest { + + private QueueEntry entryWaitingFor(long seconds) { + return new QueueEntry(UUID.randomUUID(), 1000, Instant.now().minusSeconds(seconds)); + } + + @Test + @DisplayName("ratingWindow: только что вошёл — базовое окно BASE_WINDOW") + void ratingWindow_newEntry_returnsBaseWindow() { + QueueEntry entry = entryWaitingFor(0); + assertThat(entry.ratingWindow()).isEqualTo(MatchmakingService.BASE_WINDOW); + } + + @Test + @DisplayName("ratingWindow: ждёт 5 сек — окно BASE_WINDOW + WAVE_STEP") + void ratingWindow_after5Seconds_grows() { + QueueEntry entry = entryWaitingFor(5); + int expected = MatchmakingService.BASE_WINDOW + MatchmakingService.WAVE_STEP; + assertThat(entry.ratingWindow()).isEqualTo(expected); + } + + @Test + @DisplayName("ratingWindow: ждёт 10 сек — окно BASE_WINDOW + 2 * WAVE_STEP") + void ratingWindow_after10Seconds_growsFurther() { + QueueEntry entry = entryWaitingFor(10); + int expected = MatchmakingService.BASE_WINDOW + 2 * MatchmakingService.WAVE_STEP; + assertThat(entry.ratingWindow()).isEqualTo(expected); + } + + @Test + @DisplayName("ratingWindow: очень долгое ожидание — окно не превышает MAX_WINDOW") + void ratingWindow_veryLongWait_cappedAtMaxWindow() { + QueueEntry entry = entryWaitingFor(100_000); + assertThat(entry.ratingWindow()).isEqualTo(MatchmakingService.MAX_WINDOW); + } + + @Test + @DisplayName("ratingWindow: окно растёт ступенчато — каждые 5 сек") + void ratingWindow_growsInSteps() { + assertThat(entryWaitingFor(4).ratingWindow()) + .isEqualTo(MatchmakingService.BASE_WINDOW); + + assertThat(entryWaitingFor(5).ratingWindow()) + .isEqualTo(MatchmakingService.BASE_WINDOW + MatchmakingService.WAVE_STEP); + + assertThat(entryWaitingFor(9).ratingWindow()) + .isEqualTo(MatchmakingService.BASE_WINDOW + MatchmakingService.WAVE_STEP); + + assertThat(entryWaitingFor(10).ratingWindow()) + .isEqualTo(MatchmakingService.BASE_WINDOW + 2 * MatchmakingService.WAVE_STEP); + } +} \ No newline at end of file