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,62 @@
package umc.cockple.demo.domain.game.domain.service;

import org.springframework.stereotype.Component;
import umc.cockple.demo.domain.game.domain.Game;
import umc.cockple.demo.domain.game.domain.GameBoardMember;
import umc.cockple.demo.domain.game.domain.GamePlayer;
import umc.cockple.demo.domain.game.enums.GameStatus;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Component
public class GameBoardMemberAvailabilityPolicy {

private static final long PLAYING_COOLDOWN_MINUTES = 10;

public List<GameBoardMember> filterAvailable(
List<GameBoardMember> members,
List<Game> activeGames,
LocalDateTime now) {
Set<Long> blockedMemberIds = blockedMemberIds(activeGames, now);
return members.stream()
.filter(member -> Boolean.TRUE.equals(member.getParticipating()))
.filter(member -> !blockedMemberIds.contains(member.getId()))
.toList();
}

public boolean hasBlockedMember(
List<GameBoardMember> members,
List<Game> activeGames,
LocalDateTime now) {
Set<Long> blockedMemberIds = blockedMemberIds(activeGames, now);
return members.stream().anyMatch(member -> blockedMemberIds.contains(member.getId()));
}

private Set<Long> blockedMemberIds(List<Game> activeGames, LocalDateTime now) {
Set<Long> blockedMemberIds = new HashSet<>();
for (Game game : activeGames) {
if (!blocksSelection(game, now)) {
continue;
}
game.getPlayers().stream()
.map(GamePlayer::getGameBoardMember)
.map(GameBoardMember::getId)
.forEach(blockedMemberIds::add);
}
return blockedMemberIds;
}

private boolean blocksSelection(Game game, LocalDateTime now) {
if (game.getStatus() == GameStatus.WAITING) {
return true;
}
if (game.getStatus() != GameStatus.PLAYING) {
return false;
}
LocalDateTime startedAt = game.getStartedAt();
return startedAt == null || startedAt.isAfter(now.minusMinutes(PLAYING_COOLDOWN_MINUTES));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package umc.cockple.demo.domain.game.domain.service;

public record GamePairCount(
Long memberIdA,
Long memberIdB,
long count) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package umc.cockple.demo.domain.game.domain.service;

import org.springframework.stereotype.Component;
import umc.cockple.demo.domain.game.domain.Game;
import umc.cockple.demo.domain.game.domain.GamePlayer;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Component
public class GamePairHistoryCalculator {

public GamePairHistory calculate(List<Game> completedGames) {
Map<MemberPair, Integer> completedGameCounts = new HashMap<>();
for (Game completedGame : completedGames) {
for (MemberPair pair : pairsOf(completedGame)) {
completedGameCounts.merge(pair, 1, Integer::sum);
}
}

Set<MemberPair> lastGamePairs = completedGames.stream()
.max(Comparator.comparing(Game::getCompletedAt))
.map(this::pairsOf)
.map(Set::copyOf)
.orElseGet(Set::of);

return new GamePairHistory(Map.copyOf(completedGameCounts), lastGamePairs);
}

public GamePairHistory fromCounts(
List<GamePairCount> pairCounts,
List<Long> lastGameMemberIds) {
Map<MemberPair, Integer> completedGameCounts = new HashMap<>();
pairCounts.forEach(pairCount -> completedGameCounts.put(
MemberPair.of(pairCount.memberIdA(), pairCount.memberIdB()),
Math.toIntExact(pairCount.count())));

List<MemberPair> lastGamePairs = pairsOf(lastGameMemberIds);
return new GamePairHistory(
Map.copyOf(completedGameCounts),
Set.copyOf(lastGamePairs));
}

private List<MemberPair> pairsOf(Game game) {
List<Long> memberIds = game.getPlayers().stream()
.map(GamePlayer::getGameBoardMember)
.map(gameBoardMember -> gameBoardMember.getId())
.distinct()
.sorted()
.toList();

return pairsOf(memberIds);
}

private List<MemberPair> pairsOf(List<Long> memberIds) {
List<MemberPair> pairs = new ArrayList<>();
for (int i = 0; i < memberIds.size(); i++) {
for (int j = i + 1; j < memberIds.size(); j++) {
pairs.add(MemberPair.of(memberIds.get(i), memberIds.get(j)));
}
}
return pairs;
}

public static final class GamePairHistory {

private final Map<MemberPair, Integer> completedGameCounts;
private final Set<MemberPair> lastGamePairs;

private GamePairHistory(
Map<MemberPair, Integer> completedGameCounts,
Set<MemberPair> lastGamePairs) {
this.completedGameCounts = completedGameCounts;
this.lastGamePairs = lastGamePairs;
}

public int count(Long memberIdA, Long memberIdB) {
return completedGameCounts.getOrDefault(MemberPair.of(memberIdA, memberIdB), 0);
}

public boolean playedInLastGame(Long memberIdA, Long memberIdB) {
return lastGamePairs.contains(MemberPair.of(memberIdA, memberIdB));
}
}

private record MemberPair(Long lowerMemberId, Long higherMemberId) {

private static MemberPair of(Long memberIdA, Long memberIdB) {
if (memberIdA.compareTo(memberIdB) <= 0) {
return new MemberPair(memberIdA, memberIdB);
}
return new MemberPair(memberIdB, memberIdA);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package umc.cockple.demo.domain.game.domain.service.matching;

import org.springframework.stereotype.Component;
import umc.cockple.demo.domain.game.domain.GameBoardMember;
import umc.cockple.demo.domain.game.enums.AgeGroup;
import umc.cockple.demo.global.enums.Gender;
import umc.cockple.demo.global.enums.Level;

@Component
public class GameAbilityScoreCalculator {

public int calculate(GameBoardMember member) {
int baseScore = baseScore(member.getLevel());
if (member.getGender() == Gender.FEMALE) {
return baseScore - 10;
}
return baseScore + maleAgeBonus(member.getAgeGroup());
}

private int baseScore(Level level) {
return switch (level) {
case EXPERT -> 100;
case SEMI_EXPERT -> 90;
case A -> 80;
case B -> 70;
case C -> 60;
case D -> 50;
case BEGINNER -> 40;
case NOVICE -> 30;
case NONE -> throw new IllegalArgumentException(
"급수없음 선수는 능력치 점수 계산 대상이 아닙니다.");
};
}

private int maleAgeBonus(AgeGroup ageGroup) {
if (ageGroup == null) {
return 4;
}
return switch (ageGroup) {
case TEENS, TWENTIES -> 6;
case THIRTIES -> 4;
case FORTIES -> 2;
case FIFTIES, SIXTIES, SEVENTIES -> 0;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package umc.cockple.demo.domain.game.domain.service.matching;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import umc.cockple.demo.domain.game.domain.GameBoardMember;
import umc.cockple.demo.domain.game.enums.GameMatchType;
import umc.cockple.demo.domain.game.exception.GameErrorCode;
import umc.cockple.demo.domain.game.exception.GameException;
import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory;
import umc.cockple.demo.global.enums.Gender;

import java.util.List;

@Component
@RequiredArgsConstructor
public class GameBestMatchSelector {

private static final int REQUIRED_PLAYER_COUNT = 4;
private static final int BALANCE_WEIGHT = 10;
private static final int DUPLICATE_WEIGHT = 15;

private final GameAbilityScoreCalculator abilityScoreCalculator;

public List<Long> select(
List<GameBoardMember> candidatePool,
GameMatchType matchType,
GamePairHistory pairHistory) {
MatchScore bestMatch = null;
int size = candidatePool.size();
for (int first = 0; first < size - 3; first++) {
for (int second = first + 1; second < size - 2; second++) {
for (int third = second + 1; third < size - 1; third++) {
for (int fourth = third + 1; fourth < size; fourth++) {
List<GameBoardMember> members = List.of(
candidatePool.get(first),
candidatePool.get(second),
candidatePool.get(third),
candidatePool.get(fourth));
if (!matchesType(members, matchType)) {
continue;
}

MatchScore score = score(members, matchType, pairHistory);
if (bestMatch == null || score.compareTo(bestMatch) < 0) {
bestMatch = score;
}
}
}
}
}
if (bestMatch == null) {
throw new GameException(GameErrorCode.RANDOM_MATCH_NOT_FOUND);
}
return bestMatch.memberIds();
}

private boolean matchesType(List<GameBoardMember> members, GameMatchType matchType) {
if (members.size() != REQUIRED_PLAYER_COUNT) {
return false;
}
long maleCount = countByGender(members, Gender.MALE);
long femaleCount = countByGender(members, Gender.FEMALE);
return switch (matchType) {
case MIXED_DOUBLES -> maleCount == 2 && femaleCount == 2;
case MEN_DOUBLES -> maleCount == 4;
case WOMEN_DOUBLES -> femaleCount == 4;
};
}

private MatchScore score(
List<GameBoardMember> members,
GameMatchType matchType,
GamePairHistory pairHistory) {
int balanceScore = minimumTeamDifference(members, matchType) * BALANCE_WEIGHT;
int duplicateScore = duplicateScore(members, pairHistory);
int fairnessScore = members.stream()
.mapToInt(GameBoardMember::getGameCount)
.sum();
int totalScore = balanceScore + duplicateScore * DUPLICATE_WEIGHT + fairnessScore;
List<Long> memberIds = members.stream()
.map(GameBoardMember::getId)
.sorted()
.toList();
return new MatchScore(totalScore, duplicateScore, fairnessScore, memberIds);
}

private int minimumTeamDifference(
List<GameBoardMember> members,
GameMatchType matchType) {
if (matchType == GameMatchType.MIXED_DOUBLES) {
List<GameBoardMember> males = byGender(members, Gender.MALE);
List<GameBoardMember> females = byGender(members, Gender.FEMALE);
return Math.min(
teamDifference(males.get(0), females.get(0), males.get(1), females.get(1)),
teamDifference(males.get(0), females.get(1), males.get(1), females.get(0)));
}
return Math.min(
teamDifference(members.get(0), members.get(1), members.get(2), members.get(3)),
Math.min(
teamDifference(members.get(0), members.get(2), members.get(1), members.get(3)),
teamDifference(members.get(0), members.get(3), members.get(1), members.get(2))));
}

private int teamDifference(
GameBoardMember firstA,
GameBoardMember firstB,
GameBoardMember secondA,
GameBoardMember secondB) {
int firstTeamScore = abilityScoreCalculator.calculate(firstA)
+ abilityScoreCalculator.calculate(firstB);
int secondTeamScore = abilityScoreCalculator.calculate(secondA)
+ abilityScoreCalculator.calculate(secondB);
return Math.abs(firstTeamScore - secondTeamScore);
}

private int duplicateScore(
List<GameBoardMember> members,
GamePairHistory pairHistory) {
int score = 0;
for (int first = 0; first < members.size(); first++) {
for (int second = first + 1; second < members.size(); second++) {
score += pairHistory.count(
members.get(first).getId(),
members.get(second).getId());
}
}
return score;
}

private List<GameBoardMember> byGender(
List<GameBoardMember> members,
Gender gender) {
return members.stream()
.filter(member -> member.getGender() == gender)
.toList();
}

private long countByGender(List<GameBoardMember> members, Gender gender) {
return members.stream()
.filter(member -> member.getGender() == gender)
.count();
}

private record MatchScore(
int totalScore,
int duplicateScore,
int fairnessScore,
List<Long> memberIds) implements Comparable<MatchScore> {

@Override
public int compareTo(MatchScore other) {
int compared = Integer.compare(totalScore, other.totalScore);
if (compared != 0) {
return compared;
}
compared = Integer.compare(duplicateScore, other.duplicateScore);
if (compared != 0) {
return compared;
}
compared = Integer.compare(fairnessScore, other.fairnessScore);
if (compared != 0) {
return compared;
}
for (int index = 0; index < memberIds.size(); index++) {
compared = memberIds.get(index).compareTo(other.memberIds.get(index));
if (compared != 0) {
return compared;
}
}
return 0;
}
}
}
Loading
Loading