From 72100a7734c9b85f5ab775040e674a1c777d1125 Mon Sep 17 00:00:00 2001 From: CO0LGIRL Date: Wed, 20 May 2026 19:03:41 +0300 Subject: [PATCH 1/4] feat(matchmaking): implement core matchmaking logic --- .../Matchmaking/DTO/MatchStatusDTO.java | 7 ++ .../backend/Matchmaking/MatchFoundEvent.java | 20 ++++ .../Matchmaking/MatchmakingController.java | 47 +++++++++ .../Matchmaking/MatchmakingScheduler.java | 21 ++++ .../Matchmaking/MatchmakingService.java | 99 +++++++++++++++++++ .../backend/Matchmaking/QueueEntry.java | 16 +++ 6 files changed, 210 insertions(+) create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/DTO/MatchStatusDTO.java create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchmakingScheduler.java create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/QueueEntry.java 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..3908907 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/DTO/MatchStatusDTO.java @@ -0,0 +1,7 @@ +package com.codzilla.backend.Matchmaking.dto; + +public record MatchStatusDTO( + String status, + int queueSize, + long waitingSeconds +) {} diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java new file mode 100644 index 0000000..70bd54c --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java @@ -0,0 +1,20 @@ +package com.codzilla.backend.Matchmaking; + +import org.springframework.context.ApplicationEvent; + +import java.util.UUID; + +public class MatchFoundEvent extends ApplicationEvent { + + private final UUID playerOneId; + private final UUID playerTwoId; + + public MatchFoundEvent(Object source, UUID playerOneId, UUID playerTwoId) { + super(source); + this.playerOneId = playerOneId; + this.playerTwoId = playerTwoId; + } + + public UUID getPlayerOneId() { return playerOneId; } + public UUID getPlayerTwoId() { return playerTwoId; } +} 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..b0326c7 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java @@ -0,0 +1,47 @@ +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) { + return ResponseEntity.ok(matchmakingService.queueStatus(resolveUserId(user))); + } +} \ 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..7483cb3 --- /dev/null +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java @@ -0,0 +1,99 @@ +package com.codzilla.backend.Matchmaking; + +import com.codzilla.backend.Authentication.Exceptions.UserNotFoundException; +import com.codzilla.backend.Matchmaking.dto.MatchStatusDTO; +import com.codzilla.backend.User.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +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; + private final ApplicationEventPublisher eventPublisher; + + public MatchmakingService(UserRepository userRepository, + ApplicationEventPublisher eventPublisher) { + this.userRepository = userRepository; + this.eventPublisher = eventPublisher; + } + + 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 MatchStatusDTO queueStatus(UUID userId) { + QueueEntry entry = queue.get(userId); + if (entry == null) { + return new MatchStatusDTO("NOT_IN_QUEUE", queue.size(), 0); + } + long waiting = Instant.now().getEpochSecond() - entry.joinedAt().getEpochSecond(); + return new MatchStatusDTO("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; + } + + log.info("[MM] Paired: {} vs {}", a, b); + eventPublisher.publishEvent(new MatchFoundEvent(this, 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); + } +} From 9189dd62b9169c80dda44d85676b8ea2d6610b88 Mon Sep 17 00:00:00 2001 From: CO0LGIRL Date: Wed, 20 May 2026 19:04:36 +0300 Subject: [PATCH 2/4] test(matchmaking): add unit tests for MatchmakingService --- .../Matchmaking/MatchmakingServiceTest.java | 251 ++++++++++++++++++ .../backend/Matchmaking/QueueEntryTest.java | 62 +++++ 2 files changed, 313 insertions(+) create mode 100644 src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java create mode 100644 src/test/java/com/codzilla/backend/Matchmaking/QueueEntryTest.java 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..5572d36 --- /dev/null +++ b/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java @@ -0,0 +1,251 @@ +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +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; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private MatchmakingService service; + + private ConcurrentHashMap queue; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + service = new MatchmakingService(userRepository, eventPublisher); + + 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_pairsPlayersWithCloseRating() { + UUID a = addToQueue(1000); + UUID b = addToQueue(1020); + + service.runMatchmaking(); + + assertThat(queue).doesNotContainKey(a).doesNotContainKey(b); + + verify(eventPublisher, times(1)).publishEvent(any(MatchFoundEvent.class)); + } + + @Test + @DisplayName("runMatchmaking: событие содержит правильные userId") + void runMatchmaking_eventContainsCorrectUserIds() { + UUID a = addToQueue(1000); + UUID b = addToQueue(1030); + + service.runMatchmaking(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MatchFoundEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + + MatchFoundEvent event = captor.getValue(); + assertThat(event.getPlayerOneId()).isIn(a, b); + assertThat(event.getPlayerTwoId()).isIn(a, b); + assertThat(event.getPlayerOneId()).isNotEqualTo(event.getPlayerTwoId()); + } + + @Test + @DisplayName("runMatchmaking: игроки с разницей рейтинга больше окна НЕ спариваются") + void runMatchmaking_doesNotPairPlayersOutsideWindow() { + UUID a = addToQueue(1000); + UUID b = addToQueue(1100); + + service.runMatchmaking(); + + assertThat(queue).containsKey(a).containsKey(b); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("runMatchmaking: меньше двух игроков — ничего не происходит") + void runMatchmaking_lessThanTwoPlayers_doesNothing() { + addToQueue(1000); + + service.runMatchmaking(); + + assertThat(queue).hasSize(1); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("runMatchmaking: пустая очередь — ничего не происходит") + void runMatchmaking_emptyQueue_doesNothing() { + service.runMatchmaking(); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("runMatchmaking: игрок ждёт 10 сек — окно расширяется до 100, пара находится") + void runMatchmaking_expandingWindow_pairsAfterWaiting() { + UUID a = addToQueue(1000, 10); + UUID b = addToQueue(1090, 10); + + service.runMatchmaking(); + + assertThat(queue).doesNotContainKey(a).doesNotContainKey(b); + verify(eventPublisher, times(1)).publishEvent(any(MatchFoundEvent.class)); + } + + @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); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("runMatchmaking: четыре игрока формируют две пары") + void runMatchmaking_fourPlayers_twoMatches() { + UUID a = addToQueue(1000); + UUID b = addToQueue(1010); + UUID c = addToQueue(1500); + UUID d = addToQueue(1510); + + service.runMatchmaking(); + + assertThat(queue).isEmpty(); + verify(eventPublisher, times(2)).publishEvent(any(MatchFoundEvent.class)); + } + + @Test + @DisplayName("runMatchmaking: нечётное число игроков — один остаётся в очереди") + void runMatchmaking_oddNumberOfPlayers_oneRemains() { + addToQueue(1000); + addToQueue(1010); + addToQueue(1020); + + service.runMatchmaking(); + + assertThat(queue).hasSize(1); + verify(eventPublisher, times(1)).publishEvent(any(MatchFoundEvent.class)); + } + + @Test + @DisplayName("runMatchmaking: если один игрок вышел из очереди — второй возвращается обратно") + void runMatchmaking_onePlayerLeft_otherRequeued() { + UUID a = addToQueue(1000); + UUID b = addToQueue(1020); + + queue.remove(b); + + service.runMatchmaking(); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("queueStatus: игрок в очереди — статус WAITING") + void queueStatus_playerInQueue_returnsWaiting() { + UUID userId = addToQueue(1000, 5); + + var status = service.queueStatus(userId); + + assertThat(status.status()).isEqualTo("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("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 From ca7e70c72bfbe01656938bf2720a721867096bb2 Mon Sep 17 00:00:00 2001 From: CO0LGIRL Date: Tue, 26 May 2026 19:32:56 +0300 Subject: [PATCH 3/4] fix: remove event publisher, add TODO for room service integration --- .../backend/Matchmaking/MatchFoundEvent.java | 20 ---- .../Matchmaking/MatchmakingService.java | 10 +- .../Matchmaking/MatchmakingServiceTest.java | 100 +----------------- 3 files changed, 3 insertions(+), 127 deletions(-) delete mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java deleted file mode 100644 index 70bd54c..0000000 --- a/src/main/java/com/codzilla/backend/Matchmaking/MatchFoundEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.codzilla.backend.Matchmaking; - -import org.springframework.context.ApplicationEvent; - -import java.util.UUID; - -public class MatchFoundEvent extends ApplicationEvent { - - private final UUID playerOneId; - private final UUID playerTwoId; - - public MatchFoundEvent(Object source, UUID playerOneId, UUID playerTwoId) { - super(source); - this.playerOneId = playerOneId; - this.playerTwoId = playerTwoId; - } - - public UUID getPlayerOneId() { return playerOneId; } - public UUID getPlayerTwoId() { return playerTwoId; } -} diff --git a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java index 7483cb3..881b5ac 100644 --- a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java @@ -4,7 +4,6 @@ import com.codzilla.backend.Matchmaking.dto.MatchStatusDTO; import com.codzilla.backend.User.UserRepository; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.Instant; @@ -20,14 +19,10 @@ public class MatchmakingService { public static final int MAX_WINDOW = 400; private final ConcurrentHashMap queue = new ConcurrentHashMap<>(); - private final UserRepository userRepository; - private final ApplicationEventPublisher eventPublisher; - public MatchmakingService(UserRepository userRepository, - ApplicationEventPublisher eventPublisher) { + public MatchmakingService(UserRepository userRepository) { this.userRepository = userRepository; - this.eventPublisher = eventPublisher; } public void enterQueue(UUID userId) { @@ -92,8 +87,7 @@ private void pair(UUID a, UUID b) { 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); - eventPublisher.publishEvent(new MatchFoundEvent(this, a, b)); } } \ 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 index 5572d36..6c1bca9 100644 --- a/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java +++ b/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java @@ -6,10 +6,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; import java.lang.reflect.Field; import java.time.Instant; @@ -26,9 +24,6 @@ class MatchmakingServiceTest { @Mock private UserRepository userRepository; - @Mock - private ApplicationEventPublisher eventPublisher; - private MatchmakingService service; private ConcurrentHashMap queue; @@ -36,7 +31,7 @@ class MatchmakingServiceTest { @BeforeEach @SuppressWarnings("unchecked") void setUp() throws Exception { - service = new MatchmakingService(userRepository, eventPublisher); + service = new MatchmakingService(userRepository); Field queueField = MatchmakingService.class.getDeclaredField("queue"); queueField.setAccessible(true); @@ -104,36 +99,6 @@ void leaveQueue_nonExistentUser_doesNotThrow() { assertThatNoException().isThrownBy(() -> service.leaveQueue(UUID.randomUUID())); } - @Test - @DisplayName("runMatchmaking: два игрока с близким рейтингом спариваются") - void runMatchmaking_pairsPlayersWithCloseRating() { - UUID a = addToQueue(1000); - UUID b = addToQueue(1020); - - service.runMatchmaking(); - - assertThat(queue).doesNotContainKey(a).doesNotContainKey(b); - - verify(eventPublisher, times(1)).publishEvent(any(MatchFoundEvent.class)); - } - - @Test - @DisplayName("runMatchmaking: событие содержит правильные userId") - void runMatchmaking_eventContainsCorrectUserIds() { - UUID a = addToQueue(1000); - UUID b = addToQueue(1030); - - service.runMatchmaking(); - - ArgumentCaptor captor = ArgumentCaptor.forClass(MatchFoundEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); - - MatchFoundEvent event = captor.getValue(); - assertThat(event.getPlayerOneId()).isIn(a, b); - assertThat(event.getPlayerTwoId()).isIn(a, b); - assertThat(event.getPlayerOneId()).isNotEqualTo(event.getPlayerTwoId()); - } - @Test @DisplayName("runMatchmaking: игроки с разницей рейтинга больше окна НЕ спариваются") void runMatchmaking_doesNotPairPlayersOutsideWindow() { @@ -143,7 +108,6 @@ void runMatchmaking_doesNotPairPlayersOutsideWindow() { service.runMatchmaking(); assertThat(queue).containsKey(a).containsKey(b); - verify(eventPublisher, never()).publishEvent(any()); } @Test @@ -154,27 +118,6 @@ void runMatchmaking_lessThanTwoPlayers_doesNothing() { service.runMatchmaking(); assertThat(queue).hasSize(1); - verify(eventPublisher, never()).publishEvent(any()); - } - - @Test - @DisplayName("runMatchmaking: пустая очередь — ничего не происходит") - void runMatchmaking_emptyQueue_doesNothing() { - service.runMatchmaking(); - - verify(eventPublisher, never()).publishEvent(any()); - } - - @Test - @DisplayName("runMatchmaking: игрок ждёт 10 сек — окно расширяется до 100, пара находится") - void runMatchmaking_expandingWindow_pairsAfterWaiting() { - UUID a = addToQueue(1000, 10); - UUID b = addToQueue(1090, 10); - - service.runMatchmaking(); - - assertThat(queue).doesNotContainKey(a).doesNotContainKey(b); - verify(eventPublisher, times(1)).publishEvent(any(MatchFoundEvent.class)); } @Test @@ -186,47 +129,6 @@ void runMatchmaking_windowCappedAtMaxWindow() { service.runMatchmaking(); assertThat(queue).containsKey(a).containsKey(b); - verify(eventPublisher, never()).publishEvent(any()); - } - - @Test - @DisplayName("runMatchmaking: четыре игрока формируют две пары") - void runMatchmaking_fourPlayers_twoMatches() { - UUID a = addToQueue(1000); - UUID b = addToQueue(1010); - UUID c = addToQueue(1500); - UUID d = addToQueue(1510); - - service.runMatchmaking(); - - assertThat(queue).isEmpty(); - verify(eventPublisher, times(2)).publishEvent(any(MatchFoundEvent.class)); - } - - @Test - @DisplayName("runMatchmaking: нечётное число игроков — один остаётся в очереди") - void runMatchmaking_oddNumberOfPlayers_oneRemains() { - addToQueue(1000); - addToQueue(1010); - addToQueue(1020); - - service.runMatchmaking(); - - assertThat(queue).hasSize(1); - verify(eventPublisher, times(1)).publishEvent(any(MatchFoundEvent.class)); - } - - @Test - @DisplayName("runMatchmaking: если один игрок вышел из очереди — второй возвращается обратно") - void runMatchmaking_onePlayerLeft_otherRequeued() { - UUID a = addToQueue(1000); - UUID b = addToQueue(1020); - - queue.remove(b); - - service.runMatchmaking(); - - verify(eventPublisher, never()).publishEvent(any()); } @Test From ef2ab1fed945302eb761294b8aef8221b01585a5 Mon Sep 17 00:00:00 2001 From: CO0LGIRL Date: Wed, 27 May 2026 14:01:46 +0300 Subject: [PATCH 4/4] refactor: return record from MatchmakingService, map to DTO in controller --- .../java/com/codzilla/backend/Matchmaking/MatchStatus.java | 6 ++++++ .../codzilla/backend/Matchmaking/MatchStatusResult.java | 7 +++++++ .../backend/Matchmaking/MatchmakingController.java | 7 ++++++- .../codzilla/backend/Matchmaking/MatchmakingService.java | 7 +++---- .../backend/Matchmaking/{DTO => dto}/MatchStatusDTO.java | 6 ++++-- .../backend/Matchmaking/MatchmakingServiceTest.java | 4 ++-- 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchStatus.java create mode 100644 src/main/java/com/codzilla/backend/Matchmaking/MatchStatusResult.java rename src/main/java/com/codzilla/backend/Matchmaking/{DTO => dto}/MatchStatusDTO.java (59%) 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 index b0326c7..8951430 100644 --- a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingController.java @@ -42,6 +42,11 @@ public ResponseEntity leaveQueue(@AuthenticationPrincipal User user) { @GetMapping("/queue/status") public ResponseEntity queueStatus(@AuthenticationPrincipal User user) { - return ResponseEntity.ok(matchmakingService.queueStatus(resolveUserId(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/MatchmakingService.java b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java index 881b5ac..b499913 100644 --- a/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java +++ b/src/main/java/com/codzilla/backend/Matchmaking/MatchmakingService.java @@ -1,7 +1,6 @@ package com.codzilla.backend.Matchmaking; import com.codzilla.backend.Authentication.Exceptions.UserNotFoundException; -import com.codzilla.backend.Matchmaking.dto.MatchStatusDTO; import com.codzilla.backend.User.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -37,13 +36,13 @@ public void leaveQueue(UUID userId) { log.info("[MM] User {} left queue. Size: {}", userId, queue.size()); } - public MatchStatusDTO queueStatus(UUID userId) { + public MatchStatusResult queueStatus(UUID userId) { QueueEntry entry = queue.get(userId); if (entry == null) { - return new MatchStatusDTO("NOT_IN_QUEUE", queue.size(), 0); + return new MatchStatusResult(MatchStatus.NOT_IN_QUEUE, queue.size(), 0); } long waiting = Instant.now().getEpochSecond() - entry.joinedAt().getEpochSecond(); - return new MatchStatusDTO("WAITING", queue.size(), waiting); + return new MatchStatusResult(MatchStatus.WAITING, queue.size(), waiting); } public synchronized void runMatchmaking() { diff --git a/src/main/java/com/codzilla/backend/Matchmaking/DTO/MatchStatusDTO.java b/src/main/java/com/codzilla/backend/Matchmaking/dto/MatchStatusDTO.java similarity index 59% rename from src/main/java/com/codzilla/backend/Matchmaking/DTO/MatchStatusDTO.java rename to src/main/java/com/codzilla/backend/Matchmaking/dto/MatchStatusDTO.java index 3908907..1eb2f00 100644 --- a/src/main/java/com/codzilla/backend/Matchmaking/DTO/MatchStatusDTO.java +++ b/src/main/java/com/codzilla/backend/Matchmaking/dto/MatchStatusDTO.java @@ -1,7 +1,9 @@ package com.codzilla.backend.Matchmaking.dto; +import com.codzilla.backend.Matchmaking.MatchStatus; + public record MatchStatusDTO( - String status, + 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 index 6c1bca9..9ffb7ed 100644 --- a/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java +++ b/src/test/java/com/codzilla/backend/Matchmaking/MatchmakingServiceTest.java @@ -138,7 +138,7 @@ void queueStatus_playerInQueue_returnsWaiting() { var status = service.queueStatus(userId); - assertThat(status.status()).isEqualTo("WAITING"); + assertThat(status.status()).isEqualTo(MatchStatus.WAITING); assertThat(status.queueSize()).isEqualTo(1); assertThat(status.waitingSeconds()).isGreaterThanOrEqualTo(5); } @@ -148,6 +148,6 @@ void queueStatus_playerInQueue_returnsWaiting() { void queueStatus_playerNotInQueue_returnsNotInQueue() { var status = service.queueStatus(UUID.randomUUID()); - assertThat(status.status()).isEqualTo("NOT_IN_QUEUE"); + assertThat(status.status()).isEqualTo(MatchStatus.NOT_IN_QUEUE); } } \ No newline at end of file