Skip to content
Open
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
17 changes: 17 additions & 0 deletions docs/schema.dbml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,23 @@ Table game_matches {
'''
}

Table game_match_outcome_reasons {
id bigint [primary key, increment]
created_at "timestamp with time zone" [not null]
updated_at "timestamp with time zone" [not null]

competition_id bigint [not null]
code varchar(100) [not null]
display_label varchar(255) [not null]
visible boolean [not null, default: true]

Indexes {
(competition_id, code) [unique, name: 'game_match_outcome_reasons_competition_code_key']
}
}

Ref: game_match_outcome_reasons.competition_id > competitions.id [delete: cascade]

// ─────────────────────────────────────────────────────────────────────────────
// Tournament system (double-elimination with Bo5/Bo7 series)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.bytefight.webserver.competition.domain.dto.AdminCreateCompetitionDto;
import org.bytefight.webserver.competition.domain.dto.AdminUpdateCompetitionDto;
import org.bytefight.webserver.competition.infra.CompetitionRepository;
import org.bytefight.webserver.gamematch.application.GameOutcomeReasonService;
import org.bytefight.webserver.ladder.application.LadderService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -18,6 +19,7 @@
public class AdminCompetitionService {
private final LadderService ladderService;
private final CompetitionRepository competitionRepository;
private final GameOutcomeReasonService gameOutcomeReasonService;

public Page<Competition> listCompetitions(Pageable pageable) {
return competitionRepository.findAll(pageable);
Expand All @@ -44,6 +46,7 @@ public Competition createCompetition(AdminCreateCompetitionDto input) {
// competition.setTeamSubmissionStorageSize(200 * 1000 * 1000);

competition = competitionRepository.save(competition);
gameOutcomeReasonService.ensureDefaultReasons(competition);

ladderService.createLadder(
competition, "validation", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.bytefight.webserver.gamematch.application;

import lombok.RequiredArgsConstructor;

import java.util.List;

import org.bytefight.webserver.competition.domain.Competition;
import org.bytefight.webserver.gamematch.domain.dto.GameMatchFilterOptionsDto;
import org.bytefight.webserver.gamematch.domain.dto.GameOutcomeReasonDto;
import org.bytefight.webserver.gamematch.infra.GameMatchRepository;
import org.bytefight.webserver.gamematch.infra.GameOutcomeReasonRepository;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class GameMatchFilterOptionsService {
private final GameMatchRepository gameMatchRepository;
private final GameOutcomeReasonRepository gameOutcomeReasonRepository;

public GameMatchFilterOptionsDto getFilterOptions(Competition competition) {
List<String> mapCodes = gameMatchRepository.findDistinctMapCodesByCompetition(competition);
List<GameOutcomeReasonDto> outcomeReasons =
gameOutcomeReasonRepository
.findByCompetitionAndVisibleTrueOrderByDisplayLabelAsc(competition)
.stream()
.map(GameOutcomeReasonDto::from)
.toList();
boolean hasOtherOutcomeReasons =
gameMatchRepository.existsUnregisteredOutcomeReasonByCompetition(competition);

return new GameMatchFilterOptionsDto(mapCodes, outcomeReasons, hasOtherOutcomeReasons);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.bytefight.webserver.competition.application.CompetitionAccessGuard;
import org.bytefight.webserver.competition.domain.Competition;
import org.bytefight.webserver.gamematch.domain.GameMatch;
import org.bytefight.webserver.gamematch.domain.GameOutcomeReason;
import org.bytefight.webserver.gamematch.domain.MatchReason;
import org.bytefight.webserver.gamematch.domain.MatchStatus;
import org.bytefight.webserver.gamematch.domain.dto.GameMatchDto;
Expand Down Expand Up @@ -148,6 +149,9 @@ public Page<GameMatch> getPaginatedMatches(
String submissionUuid,
MatchReason matchReason,
MatchStatus matchStatus,
String mapCode,
String outcomeReasonCode,
Boolean unregisteredOutcomeReason,
PageRequest page) {
TeamWinFilter teamWinFilter = TeamWinFilter.from(teamWin);
UUID teamId = parseOptionalUuid(teamUuid, "teamUuid");
Expand All @@ -156,13 +160,19 @@ public Page<GameMatch> getPaginatedMatches(
UUID initiatingTeamId = parseOptionalUuid(initiatingTeamUuid, "initiatingTeamUuid");
UUID notInitiatingTeamId = parseOptionalUuid(notInitiatingTeamUuid, "notInitiatingTeamUuid");
UUID submissionId = parseOptionalUuid(submissionUuid, "submissionUuid");
String normalizedMapCode = normalizeOptionalFilter(mapCode);
String normalizedOutcomeReasonCode = normalizeOptionalFilter(outcomeReasonCode);

if (teamId == null && teamWinFilter != TeamWinFilter.ANY) {
throw new IllegalArgumentException("teamUuid is required when teamWin is Win or Lose");
}
if (hasOpponentTeamName && opponentTeamId == null) {
return Page.empty(page);
}
if (Boolean.TRUE.equals(unregisteredOutcomeReason) && normalizedOutcomeReasonCode != null) {
throw new IllegalArgumentException(
"outcomeReasonCode cannot be combined with unregisteredOutcomeReason");
}

Specification<GameMatch> spec =
(root, query, cb) -> {
Expand All @@ -186,6 +196,26 @@ public Page<GameMatch> getPaginatedMatches(
predicates.add(cb.equal(root.get("status"), matchStatus));
}

if (normalizedMapCode != null) {
predicates.add(cb.equal(root.get("mapCode"), normalizedMapCode));
}

if (normalizedOutcomeReasonCode != null) {
predicates.add(cb.equal(root.get("outcomeReasonCode"), normalizedOutcomeReasonCode));
}

if (Boolean.TRUE.equals(unregisteredOutcomeReason)) {
var registeredOutcomeReason = query.subquery(Integer.class);
var outcomeReason = registeredOutcomeReason.from(GameOutcomeReason.class);
registeredOutcomeReason
.select(cb.literal(1))
.where(
cb.equal(outcomeReason.get("competition"), root.get("competition")),
cb.equal(outcomeReason.get("code"), root.get("outcomeReasonCode")));
predicates.add(cb.isNotNull(root.get("outcomeReasonCode")));
predicates.add(cb.not(cb.exists(registeredOutcomeReason)));
}

if (teamId != null) {
predicates.add(
cb.or(
Expand Down Expand Up @@ -273,6 +303,13 @@ private UUID parseOptionalUuid(String value, String fieldName) {
}
}

private String normalizeOptionalFilter(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}

private enum TeamWinFilter {
ANY,
WIN,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.bytefight.webserver.gamematch.application;

import lombok.RequiredArgsConstructor;

import java.util.List;

import org.bytefight.webserver.competition.domain.Competition;
import org.bytefight.webserver.gamematch.domain.GameOutcomeReason;
import org.bytefight.webserver.gamematch.infra.GameOutcomeReasonRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

@RequiredArgsConstructor
@Service
public class GameOutcomeReasonService {
private static final List<DefaultReason> DEFAULT_REASONS =
List.of(
new DefaultReason("timeout", "Timeout"),
new DefaultReason("code_error", "Code error"));

private final GameOutcomeReasonRepository gameOutcomeReasonRepository;

public void ensureDefaultReasons(Competition competition) {
DEFAULT_REASONS.stream()
.filter(
defaultReason ->
!gameOutcomeReasonRepository.existsByCompetitionAndCode(
competition, defaultReason.code()))
.map(
defaultReason ->
newReason(competition, defaultReason.code(), defaultReason.label()))
.forEach(gameOutcomeReasonRepository::save);
}

public GameOutcomeReason createReason(Competition competition, String code, String displayLabel) {
if (gameOutcomeReasonRepository.existsByCompetitionAndCode(competition, code)) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "Outcome reason code already exists for this competition");
}
return gameOutcomeReasonRepository.save(newReason(competition, code, displayLabel));
}

public List<GameOutcomeReason> listReasons(Long competitionId) {
return gameOutcomeReasonRepository.findByCompetitionIdOrderByCodeAsc(competitionId);
}

public GameOutcomeReason updateReasonConfiguration(
Long id, String displayLabel, Boolean visible) {
GameOutcomeReason reason =
gameOutcomeReasonRepository
.findById(id)
.orElseThrow(
() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Outcome reason not found"));
if (displayLabel != null) {
reason.setDisplayLabel(displayLabel);
}
if (visible != null) {
reason.setVisible(visible);
}
return gameOutcomeReasonRepository.save(reason);
}

private GameOutcomeReason newReason(Competition competition, String code, String displayLabel) {
GameOutcomeReason reason = new GameOutcomeReason();
reason.setCompetition(competition);
reason.setCode(code);
reason.setDisplayLabel(displayLabel);
reason.setVisible(true);
return reason;
}

private record DefaultReason(String code, String label) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.bytefight.webserver.gamematch.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import org.bytefight.webserver.common.domain.BaseEntity;
import org.bytefight.webserver.competition.domain.Competition;

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(
name = "game_match_outcome_reasons",
uniqueConstraints = @UniqueConstraint(columnNames = {"competition_id", "code"}))
public class GameOutcomeReason extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "competition_id", nullable = false)
private Competition competition;

@Column(name = "code", nullable = false, length = 100)
private String code;

@Column(name = "display_label", nullable = false, length = 255)
private String displayLabel;

@Column(name = "visible", nullable = false)
private boolean visible = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.bytefight.webserver.gamematch.domain.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record AdminCreateGameOutcomeReasonDto(
@NotNull Long competitionId,
@NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9_]+$") String code,
@NotBlank @Size(max = 255) String displayLabel) {}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public class AdminGameMatchDto {
@NotNull Long submissionBId;
MatchStatus status;
MatchReason reason;
String mapCode;
String outcomeReasonCode;
Instant scheduledAt;
Instant startedAt;
Instant finishedAt;
Expand All @@ -42,6 +44,8 @@ public static AdminGameMatchDto fromEntity(GameMatch gameMatch) {
gameMatch.getSubmissionB().getId(),
gameMatch.getStatus(),
gameMatch.getReason(),
gameMatch.getMapCode(),
gameMatch.getOutcomeReasonCode(),
gameMatch.getScheduledAt(),
gameMatch.getStartedAt(),
gameMatch.getFinishedAt(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.bytefight.webserver.gamematch.domain.dto;

import lombok.Value;

import org.bytefight.webserver.gamematch.domain.GameOutcomeReason;

@Value
public class AdminGameOutcomeReasonDto {
Long id;
Long competitionId;
String code;
String displayLabel;
boolean visible;

public static AdminGameOutcomeReasonDto from(GameOutcomeReason outcomeReason) {
return new AdminGameOutcomeReasonDto(
outcomeReason.getId(),
outcomeReason.getCompetition().getId(),
outcomeReason.getCode(),
outcomeReason.getDisplayLabel(),
outcomeReason.isVisible());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.bytefight.webserver.gamematch.domain.dto;

import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record AdminUpdateGameOutcomeReasonDto(
@Size(max = 255) @Pattern(regexp = ".*\\S.*") String displayLabel, Boolean visible) {}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public class GameMatchDto {
@NotNull private Map<String, Object> matchSettings;
private MatchStatus status;
private MatchReason reason;
private String mapCode;
private String outcomeReasonCode;
@NotNull private Instant scheduledAt;
@NotNull private Instant startedAt;
@NotNull private Instant finishedAt;
Expand All @@ -49,6 +51,8 @@ public static GameMatchDto fromEntity(GameMatch gameMatch) {
.matchSettings(gameMatch.getMatchSettings())
.status(gameMatch.getStatus())
.reason(gameMatch.getReason())
.mapCode(gameMatch.getMapCode())
.outcomeReasonCode(gameMatch.getOutcomeReasonCode())
.scheduledAt(gameMatch.getScheduledAt())
.startedAt(gameMatch.getStartedAt())
.finishedAt(gameMatch.getFinishedAt())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.bytefight.webserver.gamematch.domain.dto;

import lombok.Value;

import java.util.List;

@Value
public class GameMatchFilterOptionsDto {
List<String> mapCodes;
List<GameOutcomeReasonDto> outcomeReasons;
boolean hasOtherOutcomeReasons;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.bytefight.webserver.gamematch.domain.dto;

import lombok.Value;

import org.bytefight.webserver.gamematch.domain.GameOutcomeReason;

@Value
public class GameOutcomeReasonDto {
String code;
String displayLabel;

public static GameOutcomeReasonDto from(GameOutcomeReason outcomeReason) {
return new GameOutcomeReasonDto(outcomeReason.getCode(), outcomeReason.getDisplayLabel());
}
}
Loading