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
Expand Up @@ -135,7 +135,8 @@ interface TournamentApi {
- pending.inviteCode, pending.inviteExpiresAt 은 null (초대 기간 종료)
- COMPLETED: completed 필드
- result: 1위부터 최대 4위까지 순위 아이템 목록
- hasGroupResult: 참여자 2명 이상이면 true. 클라이언트는 이 값으로 친구 토너먼트 결과 보기 버튼을 제어한다.
- isGroupTournament: 소셜(그룹) 토너먼트 여부. 참여자 2명 이상이면 true(완료 무관). 클라이언트는 이 값으로 "전체 결과 보기" 배너를 노출한다 - 첫 완주자가 누구든 새로고침 없이 배너가 보인다.
- hasGroupResult: 그룹 결과 조회 가능 여부. 완료한 플레이어가 2명 이상이면 true. 클라이언트는 이 값으로 배너 활성/비활성(다른 사람 결과가 아직 없으면 empty state)을 가른다.
- canAddItem: 결과 화면에서 아이템 담기(위시/링크/이미지)가 가능하면 true. ROOT 소유자·소셜 초대 CLONE 소유자는 true, 플레이링크 CLONE 소유자는 false.
나머지 필드는 응답에 포함되지 않는다.
""",
Expand Down Expand Up @@ -615,7 +616,7 @@ interface TournamentApi {
이때 클라이언트는 GET /tournaments/{id} 를 다시 호출해 다음 라운드를 받는다.
결승(currentRound=2) 결과 기록 시 completed 에 본인의 순위 결과(1위~최대 4위)가 즉시 담긴다.
소셜 토너먼트라도 각 인스턴스(ROOT·CLONE)는 해당 인스턴스의 결승이 완료되는 즉시 COMPLETED 로 전환된다.
다른 참여자의 진행 여부와 무관하게 내 결과는 바로 확인할 수 있으며, 전체 그룹 결과는 2명 이상이 완료한 뒤 hasGroupResult=true 로 활성화된다.
다른 참여자의 진행 여부와 무관하게 내 결과는 바로 확인할 수 있다. 소셜 토너먼트면 완주 즉시 isGroupTournament=true 로 "전체 결과 보기" 배너가 노출되고(새로고침 불필요), 전체 그룹 결과는 2명 이상이 완료한 뒤 hasGroupResult=true 로 활성화된다.
""",
)
@ApiResponses(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ class TournamentApiExamples(
),
),
hasGroupResult = true,
isGroupTournament = true,
canAddItem = true,
playLinkExpiresAt = LocalDateTime.of(2026, 6, 20, 22, 0, 0),
),
Expand Down Expand Up @@ -563,6 +564,7 @@ class TournamentApiExamples(
),
),
hasGroupResult = true,
isGroupTournament = true,
canAddItem = true,
playLinkExpiresAt = LocalDateTime.of(2026, 6, 20, 22, 0, 0),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ data class TournamentDetailResponse(
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CompletedData(
val result: List<RankedItemResponse>,
// 그룹 결과 "조회 가능" 여부 — 완료 플레이어 수가 2명 이상이면 true. 클라는 이 값으로 배너 활성/비활성(다른
// 사람 결과가 아직 없으면 empty state)을 가른다.
val hasGroupResult: Boolean,
// 소셜(그룹) 토너먼트 여부 — 참여자 수가 2명 이상이면 true(완료 무관). 클라는 이 값으로 "전체 결과 보기" 배너
// 노출을 가른다 — 첫 완주자가 누구든 새로고침 없이 배너를 본다(#975).
val isGroupTournament: Boolean,
// true: ROOT 소유자 또는 소셜 초대 CLONE 소유자 — 아이템 담기 허용(위시/링크/이미지).
// false: 플레이링크 CLONE 소유자 — 아이템 담기 불가.
val canAddItem: Boolean,
Expand All @@ -115,6 +120,7 @@ data class TournamentDetailResponse(
CompletedData(
result = completed.result.map { RankedItemResponse.from(it) },
hasGroupResult = completed.hasGroupResult,
isGroupTournament = completed.isGroupTournament,
canAddItem = completed.canAddItem,
playLinkExpiresAt = completed.playLinkExpiresAt,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ class TournamentService(
val userHistories = tournamentRepository.findHistoriesByTournamentIdAndTournamentUserId(
tournamentId, currentUser.getId(),
)
return buildCompleted(tournament, userHistories, computeHasGroupResult(tournament), isOwner, canAddItemForTournament(tournament, userId))
return buildCompleted(tournament, userHistories, computeGroupFlags(tournament), isOwner, canAddItemForTournament(tournament, userId))
}

// 본인 history만 사용 — 다른 참여자의 매치는 본인 진행 상태에 영향을 주지 않는다.
Expand Down Expand Up @@ -452,9 +452,9 @@ class TournamentService(
val cloneHistories = tournamentRepository.findHistoriesByTournamentIdAndTournamentUserId(
myClone.getId(), myCloneOwnerTU.getId(),
)
return buildCompleted(myClone, cloneHistories, computeHasGroupResult(tournament), false, true)
return buildCompleted(myClone, cloneHistories, computeGroupFlags(tournament), false, true)
}
buildCompleted(tournament, histories, computeHasGroupResult(tournament), isOwner, canAddItemForTournament(tournament, userId))
buildCompleted(tournament, histories, computeGroupFlags(tournament), isOwner, canAddItemForTournament(tournament, userId))
}
}
}
Expand Down Expand Up @@ -672,7 +672,7 @@ class TournamentService(
return RecordMatchResult(
nextMatch = null,
completed = buildCompleted(
tournament, histories, computeHasGroupResult(tournament),
tournament, histories, computeGroupFlags(tournament),
tournamentUser.getId() == tournament.ownerTournamentUserId,
canAddItemForTournament(tournament, userId),
),
Expand Down Expand Up @@ -742,18 +742,43 @@ class TournamentService(
return RecordMatchResult(
nextMatch = null,
completed = buildCompleted(
tournament, histories + newHistory, computeHasGroupResult(tournament), isOwner,
tournament, histories + newHistory, computeGroupFlags(tournament), isOwner,
canAddItemForTournament(tournament, userId),
),
)
}

private fun computeHasGroupResult(tournament: Tournament): Boolean {
private data class GroupFlags(
val hasGroupResult: Boolean,
val isGroupTournament: Boolean,
)

// 그룹 결과 관련 두 플래그를 한 번에 구한다 — 루트 기준 클론 목록·전체 TU 를 공유해 조회를 중복하지 않는다.
// hasGroupResult : 완료한 고유 사용자 수 >= 2 → 그룹 결과 "조회 가능"(progressive gate, core#456).
// isGroupTournament : 참여한 고유 사용자 수 >= 2 → "소셜(그룹) 토너먼트 여부"(완료 무관, core#370 원래 정의).
// 배너 "노출"은 isGroupTournament 로, "활성/비활성"은 hasGroupResult 로 가른다 — 첫 완주자가 누구든 새로고침 없이
// 배너를 본다(#975). 솔로는 참여자가 항상 정확히 1이라 false.
// record 가 아니라 userId 로 센다 — 같은 사용자가 ROOT TU 와 자기 CLONE 을 모두 가질 수 있어서다(주최자가 자기
// 플레이링크로 self-clone 을 만드는 경로에 가드가 없다). 그대로 record 를 세면 1명이 2로 잡혀 solo 가 그룹으로 오인된다.
private fun computeGroupFlags(tournament: Tournament): GroupFlags {
val rootId = tournament.sourceTournamentId ?: tournament.getId()
// 루트 토너먼트 내 완료 참여자(TU) + 완료된 클론 토너먼트 수의 합이 2 이상이면 그룹 결과를 조회할 수 있다.
val completedInRoot = tournamentUserRepository.countCompletedByTournamentId(rootId)
val completedClones = tournamentRepository.findBySourceTournamentId(rootId).count { it.isCompleted() }
return completedInRoot + completedClones >= 2
val clones = tournamentRepository.findBySourceTournamentId(rootId)
val rootUsers = tournamentUserRepository.findByTournamentId(rootId)
val cloneOwnerById = tournamentUserRepository
.findByIds(clones.map { it.ownerTournamentUserId }.toSet())
.associateBy { it.getId() }
val participantUserIds = buildSet {
rootUsers.forEach { add(it.userId) }
clones.forEach { clone -> cloneOwnerById[clone.ownerTournamentUserId]?.let { add(it.userId) } }
}
val completedUserIds = buildSet {
rootUsers.filter { it.isCompleted() }.forEach { add(it.userId) }
clones.filter { it.isCompleted() }.forEach { clone -> cloneOwnerById[clone.ownerTournamentUserId]?.let { add(it.userId) } }
}
return GroupFlags(
hasGroupResult = completedUserIds.size >= 2,
isGroupTournament = participantUserIds.size >= 2,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ROOT 는 항상 아이템 담기 가능. CLONE 은 소셜 초대로 ROOT 에 TournamentUser 가 있으면 true,
Expand All @@ -768,7 +793,7 @@ class TournamentService(
private fun buildCompleted(
tournament: Tournament,
histories: List<TournamentHistory>,
hasGroupResult: Boolean,
groupFlags: GroupFlags,
isOwner: Boolean,
canAddItem: Boolean,
): TournamentDetail.Completed {
Expand All @@ -794,7 +819,8 @@ class TournamentService(
imageUrl = snapshot.imageUrl,
)
},
hasGroupResult = hasGroupResult,
hasGroupResult = groupFlags.hasGroupResult,
isGroupTournament = groupFlags.isGroupTournament,
isOwner = isOwner,
isRoot = isRoot,
canAddItem = canAddItem,
Expand Down Expand Up @@ -1013,11 +1039,16 @@ class TournamentService(
requesterOwnedClone?.isCompleted() ?: false
}
// completedRootTUs·completedClones 는 아래 plays 빌드에도 쓰이므로 미리 구해 게이트와 공유한다.
// computeHasGroupResult 를 별도 호출하면 findBySourceTournamentId 와 countCompletedByTournamentId 를
// 중복 조회하게 되므로 인라인으로 처리한다.
// computeGroupFlags 를 별도 호출하면 findBySourceTournamentId 등을 중복 조회하게 되므로 인라인으로 처리한다.
val completedRootTUs = tournamentUserRepository.findCompletedByTournamentId(tournamentId)
val completedClones = allClones.filter { it.isCompleted() }
if (!requesterHasCompleted || completedRootTUs.size + completedClones.size < 2) {
// 완료자는 record 가 아니라 userId 로 센다 — 주최자가 자기 self-clone 을 완주하면 ROOT·CLONE 두 record 가
// 같은 사용자다(computeGroupFlags 와 동일 기준). record 로 세면 solo 가 게이트를 통과해버린다.
val completedUserIds = buildSet {
completedRootTUs.forEach { add(it.userId) }
completedClones.forEach { clone -> cloneOwnerTUById[clone.ownerTournamentUserId]?.let { add(it.userId) } }
}
if (!requesterHasCompleted || completedUserIds.size < 2) {
throw TournamentException.groupResultNotAvailable()
}

Expand All @@ -1032,7 +1063,7 @@ class TournamentService(
val ownerTU = cloneOwnerTUById[clone.ownerTournamentUserId] ?: return@forEach
add(Play(clone.getId(), ownerTU.getId(), ownerTU.userId))
}
}
}.distinctBy { it.userUUID } // 같은 사용자의 ROOT·self-clone 플레이가 결과에 두 번 실리지 않게 dedup (ROOT 플레이 우선).

val userById = userRepository
.findByIds(plays.map { it.userUUID }.toSet())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ sealed class TournamentDetail {
val tournamentId: Long,
val name: String,
val result: List<RankedItem>,
// 그룹 결과 "조회 가능" 여부 — 완료 플레이어 수 >= 2 (progressive gate).
val hasGroupResult: Boolean,
// 소셜(그룹) 토너먼트 여부 — 참여자 수 >= 2 (완료 무관). 결과 화면 배너 "노출" 을 이 값으로 가른다(#975).
val isGroupTournament: Boolean,
val isOwner: Boolean,
val isRoot: Boolean,
// true: ROOT 소유자 또는 소셜 초대로 참여한 CLONE 소유자 — 결과 화면에서 아이템 담기 허용(위시/링크/이미지).
Expand Down
Loading
Loading