Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.codzilla.backend.Matchmaking;

public enum MatchStatus {
WAITING,
NOT_IN_QUEUE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.codzilla.backend.Matchmaking;

public record MatchStatusResult(
MatchStatus status,
int queueSize,
long waitingSeconds
) {}
Original file line number Diff line number Diff line change
@@ -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<Void> enterQueue(@AuthenticationPrincipal User user) {
matchmakingService.enterQueue(resolveUserId(user));
return ResponseEntity.ok().build();
}

@DeleteMapping("/queue")
public ResponseEntity<Void> leaveQueue(@AuthenticationPrincipal User user) {
matchmakingService.leaveQueue(resolveUserId(user));
return ResponseEntity.ok().build();
}

@GetMapping("/queue/status")
public ResponseEntity<MatchStatusDTO> queueStatus(@AuthenticationPrincipal User user) {
MatchStatusResult result = matchmakingService.queueStatus(resolveUserId(user));
return ResponseEntity.ok(new MatchStatusDTO(
result.status(),
result.queueSize(),
result.waitingSeconds()
));
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<UUID, QueueEntry> 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<QueueEntry> sorted = new ArrayList<>(queue.values());
sorted.sort(Comparator.comparingInt(QueueEntry::rating));

Set<UUID> 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);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/codzilla/backend/Matchmaking/QueueEntry.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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<UUID, QueueEntry> queue;

@BeforeEach
@SuppressWarnings("unchecked")
void setUp() throws Exception {
service = new MatchmakingService(userRepository);

Field queueField = MatchmakingService.class.getDeclaredField("queue");
queueField.setAccessible(true);
queue = (ConcurrentHashMap<UUID, QueueEntry>) 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);
}
}
Loading
Loading