diff --git a/src/components/contests/BracketRoomClient.module.scss b/src/components/contests/BracketRoomClient.module.scss index f24d7b6b..d6f4c50d 100644 --- a/src/components/contests/BracketRoomClient.module.scss +++ b/src/components/contests/BracketRoomClient.module.scss @@ -746,3 +746,34 @@ .vsLabel { font-family: var(--font-jetbrains-mono), monospace; } + +.viewSwitcher { + display: flex; + align-items: center; + gap: 0.25rem; + background: var(--surface); + border: 1px solid var(--border); + padding: 0.25rem; +} + +.viewTab { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--muted); + background: transparent; + border: none; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: var(--foreground); + } +} + +.viewTabActive { + background: var(--surface-secondary, var(--surface)); + color: var(--primary); + font-weight: 600; +} + diff --git a/src/components/contests/BracketRoomClient.tsx b/src/components/contests/BracketRoomClient.tsx index 951d3f27..4ba78d13 100644 --- a/src/components/contests/BracketRoomClient.tsx +++ b/src/components/contests/BracketRoomClient.tsx @@ -17,6 +17,7 @@ import type { ContestListingItem } from "@/lib/actions/contests"; import { expectAppData } from "@/lib/api/result"; import { getRoundName, + parseBracketPosition, type BracketNode, type BracketSnapshot, } from "@/types/bracket"; @@ -280,7 +281,11 @@ function MatchCardNode({ data }: NodeProps) { const isWaiting = node.status === "waiting"; const isPending = !isCompleted && !isActive && !isWaiting && !isBye; - const roundName = getRoundName(node.roundNumber, totalRounds); + const roundName = getRoundName( + node.roundNumber, + totalRounds, + node.bracketType, + ); const matchLabel = `${roundName === "Final" || roundName.startsWith("Semi") ? roundName.replace("s", "") : roundName} ${node.matchIndex + 1}`; const winnerId = node.winner; @@ -414,7 +419,11 @@ function MatchSidePanel({ const isActive = displayNode?.status === "active"; const isPending = displayNode?.status === "pending"; const roundName = displayNode - ? getRoundName(displayNode.roundNumber, totalRounds) + ? getRoundName( + displayNode.roundNumber, + totalRounds, + displayNode.bracketType, + ) : ""; const matchLabel = displayNode ? `${roundName.includes("Final") ? roundName : roundName} ${displayNode.matchIndex + 1}` @@ -711,61 +720,322 @@ export default function BracketRoomClient({ ); const hasActiveMatches = snapshot.nodes.some((n) => n.status === "active"); + const [filter, setFilter] = useState< + "all" | "upper" | "lower" | "grand_final" + >("all"); + const { nodes, edges } = useMemo(() => { const flowNodes: BracketFlowNode[] = []; const flowEdges: Edge[] = []; - const rounds: BracketNode[][] = Array.from( - { length: snapshot.totalRounds }, - () => [], + + const isDoubleElim = snapshot.bracketType === "double_elimination"; + + if (!isDoubleElim) { + const rounds: BracketNode[][] = Array.from( + { length: snapshot.totalRounds }, + () => [], + ); + snapshot.nodes.forEach((nd) => { + if (nd.roundNumber >= 1 && nd.roundNumber <= snapshot.totalRounds) + rounds[nd.roundNumber - 1].push(nd); + }); + + const X_GAP = 380; + const Y_GAP = 200; + + for (let r = 0; r < snapshot.totalRounds; r++) { + const isGrandFinal = r === snapshot.totalRounds - 1; + rounds[r].forEach((nd, i) => { + const scale = Math.pow(2, r); + const x = r * X_GAP; + const y = ((scale - 1) * Y_GAP) / 2 + i * scale * Y_GAP; + + flowNodes.push({ + id: nd.roomId, + type: isGrandFinal ? "grandFinalNode" : "matchNode", + position: { x, y }, + data: { + node: nd, + totalRounds: snapshot.totalRounds, + openMatchDetails, + }, + }); + + if (r < snapshot.totalRounds - 1) { + const pi = Math.floor(i / 2); + const parent = rounds[r + 1][pi]; + if (parent) { + const active = nd.status === "completed" && nd.winner !== null; + flowEdges.push({ + id: `e-${nd.roomId}-${parent.roomId}`, + source: nd.roomId, + target: parent.roomId, + type: "smoothstep", + animated: active, + style: { + stroke: active ? "var(--success)" : "var(--border)", + strokeWidth: 2, + }, + }); + } + } + }); + } + return { nodes: flowNodes, edges: flowEdges }; + } + + // ── Double Elimination Layout ──────────────────────────────────── + const upperNodes = snapshot.nodes.filter((n) => { + const stage = parseBracketPosition(n.bracketPosition || "").stage; + return stage === "upper"; + }); + const lowerNodes = snapshot.nodes.filter((n) => { + const stage = parseBracketPosition(n.bracketPosition || "").stage; + return stage === "lower"; + }); + const gfNode = snapshot.nodes.find((n) => { + const stage = parseBracketPosition(n.bracketPosition || "").stage; + return stage === "grand_final"; + }); + + const U = + snapshot.upperRounds || + Math.max( + ...upperNodes.map( + (n) => parseBracketPosition(n.bracketPosition).roundIndex + 1, + ), + 1, + ); + const L = + snapshot.lowerRounds || + Math.max( + ...lowerNodes.map( + (n) => parseBracketPosition(n.bracketPosition).roundIndex + 1, + ), + 1, + ); + + const upperRounds: BracketNode[][] = Array.from({ length: U }, () => []); + upperNodes.forEach((n) => { + const pos = parseBracketPosition(n.bracketPosition); + if (pos.roundIndex >= 0 && pos.roundIndex < U) { + upperRounds[pos.roundIndex].push(n); + } + }); + upperRounds.forEach((rnd) => + rnd.sort( + (a, b) => + parseBracketPosition(a.bracketPosition).matchIndex - + parseBracketPosition(b.bracketPosition).matchIndex, + ), ); - snapshot.nodes.forEach((nd) => { - if (nd.roundNumber >= 1 && nd.roundNumber <= snapshot.totalRounds) - rounds[nd.roundNumber - 1].push(nd); + + const lowerRounds: BracketNode[][] = Array.from({ length: L }, () => []); + lowerNodes.forEach((n) => { + const pos = parseBracketPosition(n.bracketPosition); + if (pos.roundIndex >= 0 && pos.roundIndex < L) { + lowerRounds[pos.roundIndex].push(n); + } }); + lowerRounds.forEach((rnd) => + rnd.sort( + (a, b) => + parseBracketPosition(a.bracketPosition).matchIndex - + parseBracketPosition(b.bracketPosition).matchIndex, + ), + ); const X_GAP = 380; - const Y_GAP = 200; - - for (let r = 0; r < snapshot.totalRounds; r++) { - const isGrandFinal = r === snapshot.totalRounds - 1; - rounds[r].forEach((nd, i) => { - const scale = Math.pow(2, r); - const x = r * X_GAP; - const y = ((scale - 1) * Y_GAP) / 2 + i * scale * Y_GAP; - - flowNodes.push({ - id: nd.roomId, - type: isGrandFinal ? "grandFinalNode" : "matchNode", - position: { x, y }, - data: { - node: nd, - totalRounds: snapshot.totalRounds, - openMatchDetails, - }, + const Y_GAP = 180; + const maxUpperMatches = upperRounds[0]?.length || 2; + const upperHeight = maxUpperMatches * Y_GAP; + const lowerYOffset = filter === "all" ? upperHeight + 140 : 0; + + const showUpper = filter === "all" || filter === "upper"; + const showLower = filter === "all" || filter === "lower"; + const showGf = + filter === "all" || + filter === "grand_final" || + filter === "upper" || + filter === "lower"; + + // 1. Position Upper Nodes + if (showUpper) { + for (let u = 0; u < U; u++) { + const scale = Math.pow(2, u); + upperRounds[u].forEach((nd, i) => { + const x = u * X_GAP; + const y = ((scale - 1) * Y_GAP) / 2 + i * scale * Y_GAP; + + flowNodes.push({ + id: nd.roomId, + type: "matchNode", + position: { x, y }, + data: { + node: nd, + totalRounds: U, + openMatchDetails, + }, + }); + + // Upper -> Next Upper Edge + if (u < U - 1) { + const pi = Math.floor(i / 2); + const parent = upperRounds[u + 1]?.[pi]; + if (parent) { + const active = nd.status === "completed" && nd.winner !== null; + flowEdges.push({ + id: `e-${nd.roomId}-${parent.roomId}`, + source: nd.roomId, + target: parent.roomId, + type: "smoothstep", + animated: active, + style: { + stroke: active ? "var(--success)" : "var(--border)", + strokeWidth: 2, + }, + }); + } + } }); + } + } - if (r < snapshot.totalRounds - 1) { - const pi = Math.floor(i / 2); - const parent = rounds[r + 1][pi]; - if (parent) { - const active = nd.status === "completed" && nd.winner !== null; + // 2. Position Lower Nodes + if (showLower) { + for (let l = 0; l < L; l++) { + lowerRounds[l].forEach((nd, i) => { + const x = l * (X_GAP * 0.88); + const y = lowerYOffset + i * Y_GAP * 1.15; + + flowNodes.push({ + id: nd.roomId, + type: "matchNode", + position: { x, y }, + data: { + node: nd, + totalRounds: L, + openMatchDetails, + }, + }); + + // Lower -> Next Lower Edge + if (l < L - 1) { + const nextMatchIdx = l % 2 === 0 ? i : Math.floor(i / 2); + const parent = lowerRounds[l + 1]?.[nextMatchIdx]; + if (parent) { + const active = nd.status === "completed" && nd.winner !== null; + flowEdges.push({ + id: `e-${nd.roomId}-${parent.roomId}`, + source: nd.roomId, + target: parent.roomId, + type: "smoothstep", + animated: active, + style: { + stroke: active ? "var(--success)" : "var(--border)", + strokeWidth: 2, + }, + }); + } + } + }); + } + } + + // 3. Drop-down edges from Upper to Lower (only in 'all' view) + if (filter === "all") { + for (let u = 0; u < U; u++) { + upperRounds[u].forEach((uNode, m) => { + let targetLowerNode: BracketNode | undefined; + if (u === 0) { + targetLowerNode = lowerRounds[0]?.[Math.floor(m / 2)]; + } else if (u < U - 1) { + const targetLowerRoundIdx = 2 * u - 1; + targetLowerNode = lowerRounds[targetLowerRoundIdx]?.[m]; + } else { + targetLowerNode = lowerRounds[L - 1]?.[0]; + } + + if (targetLowerNode) { flowEdges.push({ - id: `e-${nd.roomId}-${parent.roomId}`, - source: nd.roomId, - target: parent.roomId, + id: `e-drop-${uNode.roomId}-${targetLowerNode.roomId}`, + source: uNode.roomId, + target: targetLowerNode.roomId, type: "smoothstep", - animated: active, style: { - stroke: active ? "var(--success)" : "var(--border)", - strokeWidth: 2, + stroke: "var(--warning, #f59e0b)", + strokeDasharray: "4 4", + strokeWidth: 1.5, }, }); } - } + }); + } + } + + // 4. Position Grand Final Node + if (gfNode && showGf) { + const gfX = + filter === "grand_final" + ? 0 + : Math.max(U * X_GAP, L * (X_GAP * 0.88)) + 40; + const gfY = + filter === "grand_final" + ? 0 + : filter === "all" + ? (upperHeight + lowerYOffset) / 2 - 50 + : 100; + + flowNodes.push({ + id: gfNode.roomId, + type: "grandFinalNode", + position: { x: gfX, y: gfY }, + data: { + node: gfNode, + totalRounds: 1, + openMatchDetails, + }, }); + + // Upper Final to Grand Final Edge + const upperFinal = upperRounds[U - 1]?.[0]; + if (upperFinal && showUpper) { + const active = + upperFinal.status === "completed" && upperFinal.winner !== null; + flowEdges.push({ + id: `e-${upperFinal.roomId}-${gfNode.roomId}`, + source: upperFinal.roomId, + target: gfNode.roomId, + type: "smoothstep", + animated: active, + style: { + stroke: active ? "var(--success)" : "var(--border)", + strokeWidth: 2, + }, + }); + } + + // Lower Final to Grand Final Edge + const lowerFinal = lowerRounds[L - 1]?.[0]; + if (lowerFinal && showLower) { + const active = + lowerFinal.status === "completed" && lowerFinal.winner !== null; + flowEdges.push({ + id: `e-${lowerFinal.roomId}-${gfNode.roomId}`, + source: lowerFinal.roomId, + target: gfNode.roomId, + type: "smoothstep", + animated: active, + style: { + stroke: active ? "var(--success)" : "var(--border)", + strokeWidth: 2, + }, + }); + } } + return { nodes: flowNodes, edges: flowEdges }; - }, [snapshot, openMatchDetails]); + }, [snapshot, openMatchDetails, filter]); return (
@@ -779,7 +1049,11 @@ export default function BracketRoomClient({

{contest.name}

- Knockout + + {snapshot.bracketType === "double_elimination" + ? "Double Elimination" + : "Knockout"} +

Contests • {currentRoundName} •{" "} @@ -788,6 +1062,29 @@ export default function BracketRoomClient({

+ {snapshot.bracketType === "double_elimination" && ( +
+ {( + [ + { id: "all", label: "All Brackets" }, + { id: "upper", label: "Upper Bracket" }, + { id: "lower", label: "Lower Bracket" }, + { id: "grand_final", label: "Grand Finals" }, + ] as const + ).map((tab) => ( + + ))} +
+ )} {/* Live SSE indicator - only show "Live" */}
diff --git a/src/components/contests/ContestProblemConfiguration.tsx b/src/components/contests/ContestProblemConfiguration.tsx index c2c56af7..e1268b11 100644 --- a/src/components/contests/ContestProblemConfiguration.tsx +++ b/src/components/contests/ContestProblemConfiguration.tsx @@ -83,6 +83,56 @@ export default function ContestProblemConfiguration({ )}
+ {form.mode === "arena" ? ( +
+ + + updateForm({ + overallDurationMinutes: + parseInt(event.target.value, 10) || 60, + }) + } + disabled={presetLocked} + className={styles.formInput} + /> + + Overall countdown for the arena match (5 - 300 minutes). + +
+ ) : ( +
+ + + updateForm({ + perProblemDurationMinutes: + parseInt(event.target.value, 10) || 15, + }) + } + disabled={presetLocked} + className={styles.formInput} + /> + + Countdown per problem in blitz mode (1 - 60 minutes). + +
+ )} +
-
+
{form.fineTunedProblems.map((problem, index) => ( -
- - { - const fineTunedProblems = [...form.fineTunedProblems]; - fineTunedProblems[index] = event.target.value; - updateForm({ fineTunedProblems }); - }} - disabled={presetLocked} - className={styles.formInput} - /> +
+
+ + { + const fineTunedProblems = [...form.fineTunedProblems]; + fineTunedProblems[index] = event.target.value; + updateForm({ fineTunedProblems }); + }} + disabled={presetLocked} + className={styles.formInput} + /> +
+
+ + { + const fineTunedProblemPoints = [ + ...(form.fineTunedProblemPoints || []), + ]; + fineTunedProblemPoints[index] = + parseInt(event.target.value, 10) || 100; + updateForm({ fineTunedProblemPoints }); + }} + disabled={presetLocked} + className={styles.formInput} + /> +
+
+ + { + const fineTunedProblemTimeLimits = [ + ...(form.fineTunedProblemTimeLimits || []), + ]; + const val = parseInt(event.target.value, 10); + fineTunedProblemTimeLimits[index] = isNaN(val) + ? (undefined as unknown as number) + : val; + updateForm({ fineTunedProblemTimeLimits }); + }} + disabled={presetLocked} + className={styles.formInput} + /> +
))}
diff --git a/src/components/contests/CreateRoomModal.tsx b/src/components/contests/CreateRoomModal.tsx index d8cc7f3c..16199288 100644 --- a/src/components/contests/CreateRoomModal.tsx +++ b/src/components/contests/CreateRoomModal.tsx @@ -64,7 +64,9 @@ export default function CreateRoomModal({ return styles.ratingRed; }; - const [formData, setFormData] = useState(createInitialContestForm); + const [formData, setFormData] = useState(() => + createInitialContestForm(isHead), + ); const [registeredUsers, setRegisteredUsers] = useState( [], @@ -288,6 +290,17 @@ export default function CreateRoomModal({ return; } + const fineTunedSlots = + formData.problemSelectionMode === "fine-tuned" && + formData.fineTunedProblems.length > 0 + ? formData.fineTunedProblems.map((pid, idx) => ({ + platform: "codeforces", + problemId: pid.trim(), + points: formData.fineTunedProblemPoints?.[idx] ?? 100, + timeLimitMinutes: formData.fineTunedProblemTimeLimits?.[idx], + })) + : undefined; + setLoading(true); try { const res = await createRoomContest({ @@ -295,6 +308,7 @@ export default function CreateRoomModal({ startTime: start.toISOString(), registrationStartTime: regStartIso, registeredUsers: finalRegisteredUsers, + problemSlots: fineTunedSlots, }); if (!res.ok) { toast.error(res.error.message); @@ -560,13 +574,19 @@ export default function CreateRoomModal({ onChange={(e) => setFormData({ ...formData, format: e.target.value }) } - disabled={!!topPresetId} + disabled={!!topPresetId || !isHead} className={`${styles.formInput} ${styles.formSelect}`} > - - - + + +
@@ -732,6 +752,32 @@ export default function CreateRoomModal({
+
+ + +
+
- Scheduled rooms start automatically. Registration deadline will be - exactly 1 minute before the start time for all users. + {formData.format === "1v1" && + formData.registrationType === "closed" + ? "Casual 1v1 matches can start as soon as 1 minute from now." + : `Scheduled tournaments start automatically. Registration deadline is ${deadlineMinutes} minute${deadlineMinutes > 1 ? "s" : ""} before the start time.`} diff --git a/src/components/contests/contestCreationForm.ts b/src/components/contests/contestCreationForm.ts index 826e0660..25caa116 100644 --- a/src/components/contests/contestCreationForm.ts +++ b/src/components/contests/contestCreationForm.ts @@ -13,12 +13,17 @@ export interface ContestCreationForm { bulkMinContestId: number; fineTunedProblemCount: string | number; fineTunedProblems: string[]; + fineTunedProblemPoints?: number[]; + fineTunedProblemTimeLimits?: number[]; presetId: string; thirdPlacePlayoff: boolean; seedingMethod: string; + bracketType?: "single_elimination" | "double_elimination"; registrationStartMode: string; registrationStartTime: string; registrationType: string; + overallDurationMinutes?: number; + perProblemDurationMinutes?: number; } export type { ContestPresetDto as ContestCreationPreset } from "@/lib/contests/dtos"; @@ -29,6 +34,7 @@ export interface AdminContestWizardForm { description: string; mode: "blitz" | "arena"; format: "bracket"; + bracketType?: "single_elimination" | "double_elimination"; teamSize: 1 | 3; maxParticipants: number; startTime: string; @@ -39,10 +45,14 @@ export interface AdminContestWizardForm { platform: string; problemId: string; roundNumber: number; + points?: number; + timeLimitMinutes?: number; }>; bulkProblemCount?: number; thirdPlacePlayoff: boolean; seedingMethod: "cf_rating" | "manual"; + overallDurationMinutes?: number; + perProblemDurationMinutes?: number; } export interface ContestParticipant { @@ -55,14 +65,14 @@ export interface ContestParticipant { teamName?: string; } -export function createInitialContestForm(): ContestCreationForm { +export function createInitialContestForm(isHead = true): ContestCreationForm { return { name: "", description: "", mode: "blitz", - format: "solo-tournament", + format: isHead ? "solo-tournament" : "1v1", teamSize: 1, - maxParticipants: 16, + maxParticipants: isHead ? 16 : 2, startTime: "", problemSelectionMode: "bulk", bulkRatingMin: 800, @@ -71,12 +81,17 @@ export function createInitialContestForm(): ContestCreationForm { bulkMinContestId: 0, fineTunedProblemCount: 1, fineTunedProblems: [""], + fineTunedProblemPoints: [100], + fineTunedProblemTimeLimits: [], presetId: "", thirdPlacePlayoff: false, seedingMethod: "cf_rating", + bracketType: "single_elimination", registrationStartMode: "immediate", registrationStartTime: "", - registrationType: "open", + registrationType: isHead ? "open" : "closed", + overallDurationMinutes: 60, + perProblemDurationMinutes: 15, }; } @@ -103,6 +118,10 @@ export function applyContestPreset( preset: ContestCreationPreset, ): ContestCreationForm { const problemIds = preset.problemSlots?.map((slot) => slot.problemId || ""); + const points = preset.problemSlots?.map((slot) => slot.points ?? 100); + const timeLimits = preset.problemSlots?.map( + (slot) => slot.timeLimitMinutes ?? 15, + ); return { ...form, @@ -118,6 +137,12 @@ export function applyContestPreset( bulkMinContestId: preset.bulkMinContestId ?? form.bulkMinContestId, fineTunedProblems: problemIds && problemIds.length > 0 ? problemIds : form.fineTunedProblems, + fineTunedProblemPoints: + points && points.length > 0 ? points : form.fineTunedProblemPoints, + fineTunedProblemTimeLimits: + timeLimits && timeLimits.length > 0 + ? timeLimits + : form.fineTunedProblemTimeLimits, fineTunedProblemCount: problemIds && problemIds.length > 0 ? problemIds.length diff --git a/src/lib/actions/admin/contests.ts b/src/lib/actions/admin/contests.ts index 0fd24d0e..33acc48a 100644 --- a/src/lib/actions/admin/contests.ts +++ b/src/lib/actions/admin/contests.ts @@ -246,7 +246,10 @@ async function createBracketContestAction(input: unknown) { ), // strictly before based on ENV maxParticipants: Number(data.maxParticipants), }, + overallDurationMinutes: data.overallDurationMinutes, + perProblemDurationMinutes: data.perProblemDurationMinutes, bracketSettings: { + type: data.bracketType || "single_elimination", thirdPlacePlayoff: !!data.thirdPlacePlayoff, seedingMethod: data.seedingMethod, }, diff --git a/src/lib/actions/contests.ts b/src/lib/actions/contests.ts index 8a137a19..191666b0 100644 --- a/src/lib/actions/contests.ts +++ b/src/lib/actions/contests.ts @@ -44,6 +44,7 @@ export const createBracketContest = defineAction( import mongoose from "mongoose"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; +import { isHead } from "@/lib/access/roles"; import { webEnv } from "@/lib/env/web"; import { auth } from "@/lib/auth"; @@ -56,6 +57,9 @@ import { import dbConnect from "@/lib/mongodb"; import { errorToLogMetadata, logger } from "@/lib/utils"; import { prepareSearchQuery } from "@/lib/search"; +import { auditActor } from "@/lib/audit"; +import { summarizeContest } from "@/lib/audit/summary"; +import AuditLog, { auditExpiry } from "@/models/AuditLog"; import ContestMatch from "@/models/ContestMatch"; import CPUser from "@/models/CPUser"; import ContestRoom from "@/models/ContestRoom"; @@ -393,19 +397,38 @@ async function createRoomContestAction(input: unknown) { const cpUser = await CPUser.findOne({ userId }); if (!cpUser) return appError("NOT_FOUND", "CP Profile not found"); + const userRole = session.user.access; + const isHeadUser = isHead(userRole); + if (!isHeadUser) { + if (data.format !== "1v1" || data.registrationType === "open") { + return appError( + "FORBIDDEN", + "Only heads and admins can create tournaments or open contests.", + ); + } + data.teamSize = 1; + data.maxParticipants = 2; + data.registrationType = "closed"; + } + const start = new Date(data.startTime); const deadlineMinutes = webEnv.REGISTRATION_DEADLINE_MINUTES; - const deadline = new Date(start.getTime() - deadlineMinutes * 60000); + const isCasual1v1 = + data.format === "1v1" && data.registrationType === "closed"; + const minBufferMinutes = isCasual1v1 ? 1 : deadlineMinutes + 1; - // Validate start time is at least 2 minutes from now (1 min registration + 1 min buffer) - if (start.getTime() < Date.now() + 2 * 60000 - 5000) { + if (start.getTime() < Date.now() + minBufferMinutes * 60000 - 5000) { // 5s grace period return appError( "VALIDATION_ERROR", - "Start time must be strictly at least 2 minutes ahead of current time", + `Start time must be strictly at least ${minBufferMinutes} minute${minBufferMinutes > 1 ? "s" : ""} ahead of current time`, ); } + const deadline = isCasual1v1 + ? start + : new Date(start.getTime() - deadlineMinutes * 60000); + // Format-specific backend validations and overrides let { maxParticipants, teamSize, format } = data; @@ -430,14 +453,16 @@ async function createRoomContestAction(input: unknown) { } let problemSlots: ContestProblemSlot[] = []; - if ( - data.problemSelectionMode === "fine-tuned" && - Array.isArray(data.fineTunedProblems) - ) { - problemSlots = data.fineTunedProblems.map((id: string) => ({ - platform: "codeforces", - problemId: id.trim(), - })); + if (data.problemSelectionMode === "fine-tuned") { + if (Array.isArray(data.problemSlots) && data.problemSlots.length > 0) { + problemSlots = data.problemSlots; + } else if (Array.isArray(data.fineTunedProblems)) { + problemSlots = data.fineTunedProblems.map((id: string) => ({ + platform: "codeforces", + problemId: id.trim(), + points: 100, + })); + } } const contest = new ContestMatch({ @@ -455,6 +480,16 @@ async function createRoomContestAction(input: unknown) { bulkRatingMax: data.bulkRatingMax, bulkProblemCount: data.bulkProblemCount, problemSlots: problemSlots.length > 0 ? problemSlots : undefined, + overallDurationMinutes: data.overallDurationMinutes, + perProblemDurationMinutes: data.perProblemDurationMinutes, + bracketSettings: + format === "bracket" + ? { + type: data.bracketType || "single_elimination", + thirdPlacePlayoff: data.thirdPlacePlayoff, + seedingMethod: data.seedingMethod, + } + : undefined, registrationSettings: { type: data.registrationType || "open", startTime: data.registrationStartTime @@ -473,6 +508,27 @@ async function createRoomContestAction(input: unknown) { await contest.save(); + if (isHeadUser && format !== "1v1") { + const auditNow = new Date(); + await AuditLog.create({ + actor: auditActor(session.user), + category: "contests", + action: "create", + operation: "contests.room.create", + target: { + type: "contest", + id: String(contest._id), + label: contest.name, + }, + before: {}, + after: summarizeContest( + contest.toObject() as unknown as Record, + ), + createdAt: auditNow, + expiresAt: auditExpiry(auditNow), + }); + } + // Handle scheduling based on registrationStartTime and deadline const now = Date.now(); const regStartTime = data.registrationStartTime @@ -681,6 +737,7 @@ async function createBracketContestAction(input: unknown) { const reqHeaders = await headers(); const session = await auth.api.getSession({ headers: reqHeaders }); if (!session) return appError("UNAUTHENTICATED", "Unauthorized"); + if (!isHead(session.user.access)) return appError("FORBIDDEN", "Forbidden"); const parsed = contestCreationPayloadSchema.safeParse(input); if (!parsed.success) return validationError(parsed.error); @@ -898,12 +955,34 @@ async function createBracketContestAction(input: unknown) { ), maxParticipants: Number(data.maxParticipants), }, + overallDurationMinutes: data.overallDurationMinutes, + perProblemDurationMinutes: data.perProblemDurationMinutes, bracketSettings: { + type: data.bracketType || "single_elimination", thirdPlacePlayoff: !!data.thirdPlacePlayoff, seedingMethod: data.seedingMethod || "cf_rating", }, }); + const auditNow = new Date(); + await AuditLog.create({ + actor: auditActor(session.user), + category: "contests", + action: "create", + operation: "contests.tournament.create", + target: { + type: "contest", + id: String(contest._id), + label: contest.name, + }, + before: {}, + after: summarizeContest( + contest.toObject() as unknown as Record, + ), + createdAt: auditNow, + expiresAt: auditExpiry(auditNow), + }); + const now = Date.now(); const regStartTime = data.registrationStartTime ? new Date(data.registrationStartTime).getTime() diff --git a/src/lib/api/schemas/contestAction.test.ts b/src/lib/api/schemas/contestAction.test.ts index 46895dff..22e0e2c2 100644 --- a/src/lib/api/schemas/contestAction.test.ts +++ b/src/lib/api/schemas/contestAction.test.ts @@ -105,4 +105,30 @@ describe("bracket contest invariants", () => { }), ).toEqual({ success: true }); }); + + it("parses double elimination bracketType, duration, and problem slot points/timeLimit", () => { + const payload = validPayload({ + bracketType: "double_elimination", + overallDurationMinutes: 120, + perProblemDurationMinutes: 15, + problemSlots: [ + { + platform: "codeforces", + problemId: "1000A", + points: 250, + timeLimitMinutes: 20, + }, + ], + }); + + const parsed = contestCreationPayloadSchema.safeParse(payload); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.bracketType).toBe("double_elimination"); + expect(parsed.data.overallDurationMinutes).toBe(120); + expect(parsed.data.perProblemDurationMinutes).toBe(15); + expect(parsed.data.problemSlots[0].points).toBe(250); + expect(parsed.data.problemSlots[0].timeLimitMinutes).toBe(20); + } + }); }); diff --git a/src/lib/api/schemas/contestAction.ts b/src/lib/api/schemas/contestAction.ts index 88d3c164..9d586112 100644 --- a/src/lib/api/schemas/contestAction.ts +++ b/src/lib/api/schemas/contestAction.ts @@ -30,6 +30,8 @@ export const contestProblemSlotSchema = z.object({ platform: z.string().trim().min(1).max(50), problemId: z.string().trim().min(1).max(100), roundNumber: z.number().int().min(1).optional(), + points: z.number().int().min(1).max(10000).optional(), + timeLimitMinutes: z.number().int().min(1).max(300).optional(), }); const contestCreationFields = { @@ -54,6 +56,11 @@ const contestCreationFields = { (value) => (value === "" ? undefined : value), z.union([objectIdStringSchema, z.literal("custom")]).optional(), ), + bracketType: z + .enum(["single_elimination", "double_elimination"]) + .default("single_elimination"), + overallDurationMinutes: z.number().int().min(1).max(600).optional(), + perProblemDurationMinutes: z.number().int().min(1).max(120).optional(), thirdPlacePlayoff: z.boolean().default(false), seedingMethod: contestSeedingMethodSchema.default("cf_rating"), registeredUsers: z.array(contestRegisteredUserSchema).max(256).default([]), diff --git a/src/lib/contests/bracket.ts b/src/lib/contests/bracket.ts index d078cf04..e020bc02 100644 --- a/src/lib/contests/bracket.ts +++ b/src/lib/contests/bracket.ts @@ -15,9 +15,11 @@ import User, { type UserRecord } from "@/models/User"; import { type BracketNode, type BracketSnapshot, + type BracketType, getRoundName, snakeSeed, nextPowerOf2, + parseBracketPosition, } from "@/types/bracket"; type BracketProblem = { @@ -84,6 +86,30 @@ async function initBracketRoomRedis( } } +async function cleanupContestRedisKeys( + redis: Awaited>, + contestId: string, +) { + const roomIds = await redis.sMembers(`contest:${contestId}:rooms`); + const keysToDelete: string[] = [ + `contest:${contestId}:rooms`, + `contest:${contestId}:meta`, + ]; + for (const rId of roomIds) { + keysToDelete.push( + `room:${rId}:state`, + `room:${rId}:scores`, + `room:${rId}:teams`, + `room:${rId}:problems`, + `room:${rId}:locks`, + `room:${rId}:penalty_time`, + ); + } + if (keysToDelete.length > 0) { + await redis.del(keysToDelete); + } +} + export async function generateBracket( contestId: string, solvedProblemIds?: Set, @@ -99,6 +125,11 @@ export async function generateBracket( "Contest must be in 'provisioning' status to generate bracket", ); + const bracketType = contest.bracketSettings?.type || "single_elimination"; + if (bracketType === "double_elimination") { + return generateDoubleBracket(contestId, solvedProblemIds, deferredEffects); + } + const existingRooms = await ContestRoom.countDocuments({ contestId }); if (existingRooms > 0) throw new Error("Bracket already generated for this contest"); @@ -448,6 +479,87 @@ function getTeamByMatchIndex( return assignments[index]; } +async function promoteTeamToRoom( + targetRoom: typeof ContestRoom.prototype, + sourceTeamDoc: { + name: string; + members: mongoose.Types.ObjectId[]; + teamSize?: number; + }, + contestId: mongoose.Types.ObjectId, + roundId: mongoose.Types.ObjectId, + deferredEffects?: DeferredBracketEffect[], +) { + // Idempotency: avoid creating duplicate teams if worker retries + const existingTeam = await ContestTeam.findOne({ + roomId: targetRoom._id, + name: sourceTeamDoc.name, + }); + + if (!existingTeam) { + const newTeam = await ContestTeam.create({ + roomId: targetRoom._id, + name: sourceTeamDoc.name, + members: sourceTeamDoc.members, + teamSize: sourceTeamDoc.teamSize || 1, + score: 0, + contestId, + roundId, + }); + await ContestRoom.findByIdAndUpdate(targetRoom._id, { + $addToSet: { teams: newTeam._id }, + }); + } + + // Check if room now has 2 teams + const allTeamDocs = await ContestTeam.find({ + roomId: targetRoom._id, + }).lean(); + + if (allTeamDocs.length >= 2) { + const allMemberIds = allTeamDocs.flatMap((t) => t.members); + // Atomic update: only transition from "pending" to "waiting" once! + const updatedRoom = await ContestRoom.findOneAndUpdate( + { + _id: targetRoom._id, + status: "pending", + }, + { + $set: { + status: "waiting", + participants: allMemberIds, + }, + }, + { new: true }, + ); + + if (updatedRoom) { + const contest = await ContestMatch.findById(contestId).lean(); + const targetRoomId = toStr(targetRoom._id); + const contestMode = contest?.mode || "blitz"; + const durationSeconds = contest?.overallDurationMinutes + ? contest.overallDurationMinutes * 60 + : contest?.durationSeconds || 3600; + const bracketContestId = toStr(contestId); + + await runOrDeferEffect(deferredEffects, async () => { + await initBracketRoomRedis( + await getRedis(), + targetRoomId, + contestMode, + allTeamDocs, + durationSeconds, + bracketContestId, + ); + }); + + logger.info( + `[Bracket] Room ${targetRoom._id} (${targetRoom.bracketPosition}) is now waiting with 2 teams`, + ); + } + } +} + async function seedTeamToRound( roundId: mongoose.Types.ObjectId, teamId: mongoose.Types.ObjectId, @@ -467,50 +579,13 @@ async function seedTeamToRound( const oldTeam = await ContestTeam.findById(teamId); if (!oldTeam) return; - const newTeam = await ContestTeam.create({ - roomId: targetRoom._id, - name: oldTeam.name, - members: oldTeam.members, - teamSize: oldTeam.teamSize, - score: 0, - contestId: contestId, - roundId: roundId, - }); - - await ContestRoom.findByIdAndUpdate(targetRoom._id, { - $addToSet: { teams: newTeam._id }, - }); - - const updatedRoom = await ContestRoom.findById(targetRoom._id); - if (updatedRoom && updatedRoom.teams.length === 2) { - // Populate participants for SSE presence tracking - const allTeamDocs = await ContestTeam.find({ - roomId: targetRoom._id, - }).lean(); - const allMemberIds = allTeamDocs.flatMap((t) => t.members); - updatedRoom.participants = allMemberIds; - updatedRoom.status = "waiting"; - await updatedRoom.save(); - - // Initialise Redis state so the ready route can find the room - const contest = await ContestMatch.findById(contestId).lean(); - const targetRoomId = toStr(targetRoom._id); - const contestMode = contest?.mode || "blitz"; - const durationSeconds = contest?.durationSeconds || 3600; - const bracketContestId = toStr(contestId); - await runOrDeferEffect(deferredEffects, async () => { - await initBracketRoomRedis( - await getRedis(), - targetRoomId, - contestMode, - allTeamDocs, - durationSeconds, - bracketContestId, - ); - }); - - logger.info(`[Bracket] Room ${targetRoom._id} is now ready with 2 teams`); - } + await promoteTeamToRoom( + targetRoom, + oldTeam, + contestId, + roundId, + deferredEffects, + ); } export async function advanceWinner( @@ -538,14 +613,24 @@ export async function advanceWinner( const contest = await ContestMatch.findById(contestId); if (!contest || contest.format !== "bracket") return; - const currentRound = room.currentRoundId; + const isDoubleElim = contest.bracketSettings?.type === "double_elimination"; + if (isDoubleElim) { + return advanceWinnerDoubleBracket( + roomId, + contestId, + winnerTeamId, + deferredEffects, + ); + } + const currentRound = room.currentRoundId; if (!currentRound) return; const bracketPos = room.bracketPosition; if (!bracketPos) return; - const matchIndex = parseInt(bracketPos.split("-")[1], 10); + const posInfo = parseBracketPosition(bracketPos); + const matchIndex = posInfo.matchIndex; const nextRound = await ContestRound.findOne({ contestId, @@ -579,8 +664,7 @@ export async function advanceWinner( await runOrDeferEffect(deferredEffects, async () => { const redis = await getRedis(); - const keys = await redis.keys(`contest:${contestId}:*`); - if (keys.length > 0) await redis.del(keys); + await cleanupContestRedisKeys(redis, contestId); }); return; } @@ -603,49 +687,300 @@ export async function advanceWinner( return; } - const newTeam = await ContestTeam.create({ - roomId: nextRoom._id, - name: winnerTeamDoc.name, - members: winnerTeamDoc.members, - teamSize: winnerTeamDoc.teamSize, - score: 0, - contestId: contest._id, - roundId: nextRound._id, - }); + await promoteTeamToRoom( + nextRoom, + winnerTeamDoc, + contest._id, + nextRound._id, + deferredEffects, + ); - await ContestRoom.findByIdAndUpdate(nextRoom._id, { - $addToSet: { teams: newTeam._id }, + await runOrDeferEffect(deferredEffects, async () => { + await publishContest(contestId, { + type: "contest.standing_update", + teamId: winnerTeamId, + contestId, + }); }); - const updatedRoom = await ContestRoom.findById(nextRoom._id); - if (updatedRoom && updatedRoom.teams.length === 2) { - // Populate participants so SSE presence system can track the room - const allAdvancedTeamDocs = await ContestTeam.find({ - roomId: nextRoom._id, - }).lean(); - const allAdvancedMemberIds = allAdvancedTeamDocs.flatMap((t) => t.members); - updatedRoom.participants = allAdvancedMemberIds; - updatedRoom.status = "waiting"; - await updatedRoom.save(); - - // Initialise Redis state for the next round room - const nextRoomId = toStr(nextRoom._id); - const contestMode = contest.mode || "blitz"; - const durationSeconds = contest.durationSeconds || 3600; - await runOrDeferEffect(deferredEffects, async () => { - await initBracketRoomRedis( - await getRedis(), - nextRoomId, - contestMode, - allAdvancedTeamDocs, - durationSeconds, - contestId, - ); + await runOrDeferEffect(deferredEffects, async () => { + const snapshot = await getBracketSnapshot(contestId); + await publishContest(contestId, { + type: "contest.bracket_update", + ...snapshot, }); + }); + + logger.info( + `[Bracket] Advanced team ${winnerTeamId} to room ${nextRoom._id}`, + ); +} + +async function advanceWinnerDoubleBracket( + roomId: string, + contestId: string, + winnerTeamId: string, + deferredEffects?: DeferredBracketEffect[], +) { + await dbConnect(); + const room = await ContestRoom.findById(roomId).populate<{ + currentRoundId: IContestRound; + }>("currentRoundId"); + if (!room) { + logger.warn( + `[Bracket] Room ${roomId} not found for double bracket advancement`, + ); + return; + } + + const contest = await ContestMatch.findById(contestId); + if (!contest || contest.format !== "bracket") return; + + const currentRound = room.currentRoundId; + if (!currentRound) return; + + const bracketPos = room.bracketPosition; + if (!bracketPos) return; + + const { stage, roundIndex, matchIndex } = parseBracketPosition(bracketPos); + + const winnerTeamDoc = await ContestTeam.findById(winnerTeamId); + if (!winnerTeamDoc) { + logger.warn(`[Bracket] Winner team ${winnerTeamId} not found`); + return; + } + + const winnerTeamIdStr = toStr(winnerTeamId); + const loserTeamId = room.teams.find((t) => toStr(t) !== winnerTeamIdStr); + const loserTeamDoc = loserTeamId + ? await ContestTeam.findById(loserTeamId) + : null; + + const upperRounds = await ContestRound.find({ + contestId, + bracketType: "upper", + }).sort({ roundNumber: 1 }); + const lowerRounds = await ContestRound.find({ + contestId, + bracketType: "lower", + }).sort({ roundNumber: 1 }); + const grandFinalRound = await ContestRound.findOne({ + contestId, + bracketType: "grand_final", + }); + + const totalUpperRounds = upperRounds.length; + const totalLowerRounds = lowerRounds.length; + + if (stage === "upper") { + const u = roundIndex; + const m = matchIndex; + + // 1. Advance Winner + if (u < totalUpperRounds - 1) { + const nextMatchIndex = Math.floor(m / 2); + const nextUpperRound = upperRounds[u + 1]; + const nextRooms = await ContestRoom.find({ + _id: { $in: nextUpperRound.rooms }, + }).sort({ createdAt: 1 }); + const nextRoom = nextRooms[nextMatchIndex]; + if (nextRoom) { + await promoteTeamToRoom( + nextRoom, + winnerTeamDoc, + contest._id, + nextUpperRound._id, + deferredEffects, + ); + } + } else { + // Upper Final Winner advances to Grand Final + if (grandFinalRound) { + const gfRoom = await ContestRoom.findOne({ + _id: { $in: grandFinalRound.rooms }, + }); + if (gfRoom) { + await promoteTeamToRoom( + gfRoom, + winnerTeamDoc, + contest._id, + grandFinalRound._id, + deferredEffects, + ); + } + } + } + // 2. Drop Loser + if (loserTeamDoc) { + if (u === 0) { + // Loser from Upper R1 drops to Lower R1 + const lowerMatchIndex = Math.floor(m / 2); + if (lowerRounds.length > 0) { + const l0Rooms = await ContestRoom.find({ + _id: { $in: lowerRounds[0].rooms }, + }).sort({ createdAt: 1 }); + const targetLowerRoom = l0Rooms[lowerMatchIndex]; + + // Check if the sibling Upper R1 match was a bye + const siblingMatchIndex = m ^ 1; + const u0Rooms = await ContestRoom.find({ + _id: { $in: upperRounds[0].rooms }, + }).sort({ createdAt: 1 }); + const siblingRoom = u0Rooms[siblingMatchIndex]; + const siblingWasBye = + siblingRoom && + siblingRoom.status === "ended" && + siblingRoom.teams.length <= 1; + + if (targetLowerRoom) { + if (siblingWasBye) { + // Automatic Bye win in Lower R1; advance directly to Lower R2 + await promoteTeamToRoom( + targetLowerRoom, + loserTeamDoc, + contest._id, + lowerRounds[0]._id, + deferredEffects, + ); + targetLowerRoom.status = "ended"; + await targetLowerRoom.save(); + + if (lowerRounds.length > 1) { + const l1Rooms = await ContestRoom.find({ + _id: { $in: lowerRounds[1].rooms }, + }).sort({ createdAt: 1 }); + const nextLowerRoom = l1Rooms[lowerMatchIndex]; + if (nextLowerRoom) { + await promoteTeamToRoom( + nextLowerRoom, + loserTeamDoc, + contest._id, + lowerRounds[1]._id, + deferredEffects, + ); + } + } + } else { + await promoteTeamToRoom( + targetLowerRoom, + loserTeamDoc, + contest._id, + lowerRounds[0]._id, + deferredEffects, + ); + } + } + } + } else if (u < totalUpperRounds - 1) { + // Loser from Upper R2+ drops to Lower Round (2 * u - 1) + const targetLowerRoundIndex = 2 * u - 1; + if (targetLowerRoundIndex < totalLowerRounds) { + const targetRound = lowerRounds[targetLowerRoundIndex]; + const targetRooms = await ContestRoom.find({ + _id: { $in: targetRound.rooms }, + }).sort({ createdAt: 1 }); + const targetRoom = targetRooms[m]; + if (targetRoom) { + await promoteTeamToRoom( + targetRoom, + loserTeamDoc, + contest._id, + targetRound._id, + deferredEffects, + ); + } + } + } else { + // Loser from Upper Final drops to Lower Final (last lower round) + const targetRound = lowerRounds[totalLowerRounds - 1]; + if (targetRound) { + const targetRooms = await ContestRoom.find({ + _id: { $in: targetRound.rooms }, + }).sort({ createdAt: 1 }); + const targetRoom = targetRooms[0]; + if (targetRoom) { + await promoteTeamToRoom( + targetRoom, + loserTeamDoc, + contest._id, + targetRound._id, + deferredEffects, + ); + } + } + } + } + } else if (stage === "lower") { + const l = roundIndex; + const m = matchIndex; + + // Loser is eliminated from the tournament + + // Advance Winner + if (l < totalLowerRounds - 1) { + const nextLowerRound = lowerRounds[l + 1]; + const nextRooms = await ContestRoom.find({ + _id: { $in: nextLowerRound.rooms }, + }).sort({ createdAt: 1 }); + const nextMatchIndex = l % 2 === 0 ? m : Math.floor(m / 2); + const nextRoom = nextRooms[nextMatchIndex]; + if (nextRoom) { + await promoteTeamToRoom( + nextRoom, + winnerTeamDoc, + contest._id, + nextLowerRound._id, + deferredEffects, + ); + } + } else { + // Lower Final Winner advances to Grand Final + if (grandFinalRound) { + const gfRoom = await ContestRoom.findOne({ + _id: { $in: grandFinalRound.rooms }, + }); + if (gfRoom) { + await promoteTeamToRoom( + gfRoom, + winnerTeamDoc, + contest._id, + grandFinalRound._id, + deferredEffects, + ); + } + } + } + } else if (stage === "grand_final") { + contest.winner = new mongoose.Types.ObjectId(winnerTeamId); + contest.status = "completed"; + contest.winnerName = winnerTeamDoc.name || ""; + await contest.save(); logger.info( - `[Bracket] Next room ${nextRoom._id} is now ready with 2 teams`, + `[Bracket] Double elimination contest ${contestId} completed. Champion: ${winnerTeamId}`, ); + + await runOrDeferEffect(deferredEffects, async () => { + const finalSnapshot = await getBracketSnapshot(contestId); + await publishContest(contestId, { + type: "contest.bracket_update", + ...finalSnapshot, + }); + }); + + await runOrDeferEffect(deferredEffects, async () => { + await publishContest(contestId, { + type: "contest.round_complete", + roundNumber: currentRound.roundNumber, + advancingTeams: [winnerTeamId], + }); + }); + + await runOrDeferEffect(deferredEffects, async () => { + const redis = await getRedis(); + await cleanupContestRedisKeys(redis, contestId); + }); + return; } await runOrDeferEffect(deferredEffects, async () => { @@ -665,8 +1000,414 @@ export async function advanceWinner( }); logger.info( - `[Bracket] Advanced team ${winnerTeamId} to room ${nextRoom._id}`, + `[Bracket] Double elimination advancement processed for room ${roomId} (winner: ${winnerTeamId})`, + ); +} + +async function generateDoubleBracket( + contestId: string, + solvedProblemIds?: Set, + deferredEffects?: DeferredBracketEffect[], +) { + await dbConnect(); + const contest = await ContestMatch.findById(contestId); + if (!contest) throw new Error("Contest not found"); + if (contest.format !== "bracket") + throw new Error("Contest is not a bracket format"); + if (contest.status !== "provisioning") + throw new Error( + "Contest must be in 'provisioning' status to generate bracket", + ); + + const existingRooms = await ContestRoom.countDocuments({ contestId }); + if (existingRooms > 0) + throw new Error("Bracket already generated for this contest"); + + const teamSize = contest.teamSize || 1; + const mode = contest.mode || "blitz"; + + const groupedTeams = groupRegistrationsIntoTeams( + contest.registrations ?? [], + teamSize, + ); + + const cpUsers = await CPUser.find({ + userId: { $in: groupedTeams.flatMap((t) => t.memberIds) }, + }).lean(); + const ratingMap = new Map(); + for (const u of cpUsers) { + ratingMap.set(toStr(u.userId), u.cfRating || 0); + } + + const seededTeams = groupedTeams.map((team) => { + const avgRating = + team.memberIds.reduce((sum, id) => sum + (ratingMap.get(id) || 0), 0) / + team.memberIds.length; + return { ...team, rating: avgRating }; + }); + seededTeams.sort((a, b) => b.rating - a.rating); + + const bracketSize = Math.max(4, nextPowerOf2(seededTeams.length)); + const totalUpperRounds = Math.log2(bracketSize); + const totalLowerRounds = 2 * (totalUpperRounds - 1); + const totalRounds = totalUpperRounds + totalLowerRounds + 1; + + const seededOrder = snakeSeed( + seededTeams.map((t, i) => ({ teamId: t.teamName, seed: i + 1 })), + ); + + const matchAssignments: ((typeof seededTeams)[0] | null)[] = []; + for (let i = 0; i < bracketSize; i++) { + if (i < seededOrder.length) { + const matchTeam = seededTeams.find( + (t) => t.teamName === seededOrder[i].teamId, + ); + matchAssignments.push(matchTeam || null); + } else { + matchAssignments.push(null); + } + } + + // Create Upper Rounds + const upperRounds: (typeof ContestRound.prototype)[] = []; + for (let u = 0; u < totalUpperRounds; u++) { + const roundNum = u + 1; + const round = await ContestRound.create({ + contestId: contest._id, + roundNumber: roundNum, + name: getRoundName(roundNum, totalUpperRounds, "upper"), + status: u === 0 ? ("active" as const) : ("pending" as const), + rooms: [], + bracketLevel: `upper_round${roundNum}`, + bracketType: "upper", + bracketRoundNumber: roundNum, + }); + upperRounds.push(round); + } + + // Create Lower Rounds + const lowerRounds: (typeof ContestRound.prototype)[] = []; + for (let l = 0; l < totalLowerRounds; l++) { + const roundNum = totalUpperRounds + l + 1; + const round = await ContestRound.create({ + contestId: contest._id, + roundNumber: roundNum, + name: getRoundName(l + 1, totalLowerRounds, "lower"), + status: "pending" as const, + rooms: [], + bracketLevel: `lower_round${l + 1}`, + bracketType: "lower", + bracketRoundNumber: l + 1, + }); + lowerRounds.push(round); + } + + // Create Grand Final Round + const grandFinalRound = await ContestRound.create({ + contestId: contest._id, + roundNumber: totalRounds, + name: "Grand Final", + status: "pending" as const, + rooms: [], + bracketLevel: "grand_final", + bracketType: "grand_final", + bracketRoundNumber: 1, + }); + + const allRoomIds: string[] = []; + const problemCount = contest.bulkProblemCount || 3; + const minRating = contest.bulkRatingMin || 800; + const maxRating = contest.bulkRatingMax || 1200; + const minContestId = contest.bulkMinContestId || 0; + + const totalRooms = 2 * bracketSize - 2; + let bulkProblemPool: BracketProblem[] = []; + if (contest.problemSelectionMode === "bulk") { + const totalProblemsNeeded = totalRooms * problemCount; + const excludeIds = solvedProblemIds ? Array.from(solvedProblemIds) : []; + bulkProblemPool = await ContestQuestion.aggregate([ + { + $match: { + rating: { $gte: minRating, $lte: maxRating }, + ...(minContestId > 0 ? { contestId: { $gte: minContestId } } : {}), + ...(excludeIds.length > 0 ? { problemId: { $nin: excludeIds } } : {}), + }, + }, + { $sample: { size: totalProblemsNeeded } }, + { $sort: { rating: 1 } }, + ]); + } + const fineTunedPool = (contest.problemSlots || []).filter( + (slot): slot is IProblemSlot & { problemId: string } => + Boolean(slot.problemId), + ); + + let problemPoolIndex = 0; + const contestIdObj = contest._id; + const contestProblemSelectionMode = contest.problemSelectionMode; + + async function assignProblemsToRoom(room: typeof ContestRoom.prototype) { + let assignedProblems: BracketProblem[] = []; + if (contestProblemSelectionMode === "fine-tuned") { + assignedProblems = fineTunedPool.slice(0, problemCount); + } else if (contestProblemSelectionMode === "bulk") { + assignedProblems = bulkProblemPool.slice( + problemPoolIndex, + problemPoolIndex + problemCount, + ); + problemPoolIndex += problemCount; + } else { + assignedProblems = [ + { problemId: "4A", name: "Watermelon", rating: 800 }, + { problemId: "71A", name: "Way Too Long Words", rating: 800 }, + { problemId: "158A", name: "Next Round", rating: 800 }, + ].slice(0, problemCount); + } + + if (assignedProblems.length > 0) { + const problemSet = new ContestProblemSet({ + contestId: contestIdObj, + roomId: room._id, + problems: assignedProblems.map((problem) => ({ + platform: "codeforces", + problemId: problem.problemId, + name: problem.name || problem.problemId, + rating: problem.rating || 0, + points: Math.floor((problem.rating || 1000) / 10), + })), + }); + await problemSet.save(); + + const redisProblems = assignedProblems.map((problem) => + JSON.stringify({ + problemId: problem.problemId, + name: problem.name || problem.problemId, + rating: problem.rating || 0, + points: Math.floor((problem.rating || 1000) / 10), + revealedAt: null, + }), + ); + const roomId = toStr(room._id); + await runOrDeferEffect(deferredEffects, async () => { + const redis = await getRedis(); + await redis.del(`room:${roomId}:problems`); + await redis.rPush(`room:${roomId}:problems`, redisProblems); + }); + } + } + + // 1. Generate Upper Bracket Rooms + const upperR1Byes: { + matchIndex: number; + winnerTeamId: mongoose.Types.ObjectId; + }[] = []; + + for (let u = 0; u < totalUpperRounds; u++) { + const round = upperRounds[u]; + const matchesInRound = Math.pow(2, totalUpperRounds - u - 1); + const roundRooms: mongoose.Types.ObjectId[] = []; + + for (let m = 0; m < matchesInRound; m++) { + const bracketPos = `upper-${u}-${m}`; + const leftTeamId = + u === 0 ? getTeamByMatchIndex(matchAssignments, m * 2) : null; + const rightTeamId = + u === 0 ? getTeamByMatchIndex(matchAssignments, m * 2 + 1) : null; + + const hasNoTeams = !leftTeamId && !rightTeamId; + const isBye = !hasNoTeams && (!leftTeamId || !rightTeamId); + const roomStatus = hasNoTeams + ? ("pending" as const) + : isBye + ? ("ended" as const) + : ("waiting" as const); + + const room = await ContestRoom.create({ + contestId: contest._id, + name: `${round.name} - Match ${m + 1}`, + status: roomStatus, + participants: [], + teams: [], + currentRoundId: round._id, + currentProblemIndex: 0, + firstSolvers: [], + bracketPosition: bracketPos, + }); + + const teamIds: (mongoose.Types.ObjectId | null)[] = [null, null]; + if (leftTeamId) { + const team = await ContestTeam.create({ + roomId: room._id, + name: leftTeamId.teamName, + members: leftTeamId.memberIds.map( + (id) => new mongoose.Types.ObjectId(id), + ), + teamSize, + score: 0, + contestId: contest._id, + roundId: round._id, + }); + teamIds[0] = team._id; + } + if (rightTeamId) { + const team = await ContestTeam.create({ + roomId: room._id, + name: rightTeamId.teamName, + members: rightTeamId.memberIds.map( + (id) => new mongoose.Types.ObjectId(id), + ), + teamSize, + score: 0, + contestId: contest._id, + roundId: round._id, + }); + teamIds[1] = team._id; + } + + room.teams = teamIds.filter(Boolean) as mongoose.Types.ObjectId[]; + if (roomStatus === "waiting" && leftTeamId && rightTeamId) { + room.participants = [ + ...leftTeamId.memberIds, + ...rightTeamId.memberIds, + ].map((id) => new mongoose.Types.ObjectId(id)); + } + await room.save(); + + await assignProblemsToRoom(room); + + if (u === 0 && roomStatus === "waiting") { + const round1TeamDocs = await ContestTeam.find({ + roomId: room._id, + }).lean(); + const roomId = toStr(room._id); + const durationSeconds = contest.durationSeconds || 3600; + const bracketContestId = toStr(contest._id); + await runOrDeferEffect(deferredEffects, async () => { + await initBracketRoomRedis( + await getRedis(), + roomId, + mode, + round1TeamDocs, + durationSeconds, + bracketContestId, + ); + }); + } + + roundRooms.push(room._id); + allRoomIds.push(toStr(room._id)); + + if (u === 0 && isBye && !hasNoTeams) { + const winnerTeam = teamIds[0] || teamIds[1]; + if (winnerTeam) { + await ContestTeam.findByIdAndUpdate(winnerTeam, { score: 1 }); + upperR1Byes.push({ matchIndex: m, winnerTeamId: winnerTeam }); + } + } + } + + round.rooms = roundRooms; + await round.save(); + } + + // 2. Generate Lower Bracket Rooms + let currentLowerMatches = bracketSize / 4; + for (let l = 0; l < totalLowerRounds; l++) { + const round = lowerRounds[l]; + if (l > 0 && l % 2 === 0) { + currentLowerMatches = currentLowerMatches / 2; + } + const roundRooms: mongoose.Types.ObjectId[] = []; + + for (let m = 0; m < currentLowerMatches; m++) { + const bracketPos = `lower-${l}-${m}`; + const room = await ContestRoom.create({ + contestId: contest._id, + name: `${round.name} - Match ${m + 1}`, + status: "pending" as const, + participants: [], + teams: [], + currentRoundId: round._id, + currentProblemIndex: 0, + firstSolvers: [], + bracketPosition: bracketPos, + }); + + await assignProblemsToRoom(room); + + roundRooms.push(room._id); + allRoomIds.push(toStr(room._id)); + } + + round.rooms = roundRooms; + await round.save(); + } + + // 3. Generate Grand Final Room + const grandFinalRoom = await ContestRoom.create({ + contestId: contest._id, + name: "Grand Final", + status: "pending" as const, + participants: [], + teams: [], + currentRoundId: grandFinalRound._id, + currentProblemIndex: 0, + firstSolvers: [], + bracketPosition: "grand_final-0-0", + }); + await assignProblemsToRoom(grandFinalRoom); + grandFinalRound.rooms = [grandFinalRoom._id]; + await grandFinalRound.save(); + allRoomIds.push(toStr(grandFinalRoom._id)); + + // 4. Advance Upper R1 Byes + for (const bye of upperR1Byes) { + const nextMatchIdx = Math.floor(bye.matchIndex / 2); + if (upperRounds.length > 1) { + const nextRoundRooms = await ContestRoom.find({ + _id: { $in: upperRounds[1].rooms }, + }).sort({ createdAt: 1 }); + const nextRoom = nextRoundRooms[nextMatchIdx]; + const teamDoc = await ContestTeam.findById(bye.winnerTeamId); + if (nextRoom && teamDoc) { + await promoteTeamToRoom( + nextRoom, + teamDoc, + contest._id, + upperRounds[1]._id, + deferredEffects, + ); + } + } + } + + // Redis metadata setup + await runOrDeferEffect(deferredEffects, async () => { + const redis = await getRedis(); + await redis.hSet(`contest:${contestId}:meta`, { + format: "knockout", + bracketType: "double_elimination", + currentRound: "1", + status: "provisioning", + }); + if (allRoomIds.length > 0) { + await redis.sAdd(`contest:${contestId}:rooms`, allRoomIds); + } + }); + + const snapshot = await getBracketSnapshot(contestId); + await runOrDeferEffect(deferredEffects, async () => { + const committedSnapshot = await getBracketSnapshot(contestId); + await publishContest(contestId, { + type: "contest.bracket_update", + ...committedSnapshot, + }); + }); + + logger.info( + `[Bracket] Generated double elimination bracket for contest ${contestId}: ${allRoomIds.length} rooms across ${totalRounds} rounds`, ); + return snapshot; } export async function checkRoundCompletion( @@ -745,23 +1486,37 @@ export async function checkRoundCompletion( }); }); - const nextRound = await ContestRound.findOne({ - contestId, - roundNumber: roundNumber + 1, - }); - if (nextRound) { - nextRound.status = "active"; - await nextRound.save(); - logger.info( - `[Bracket] Round ${roundNumber} complete. Advancing to round ${roundNumber + 1}`, - ); + const isDoubleElim = contest.bracketSettings?.type === "double_elimination"; + if (isDoubleElim) { + const remainingRooms = await ContestRoom.countDocuments({ + contestId, + status: { $ne: "ended" }, + }); + if (remainingRooms === 0) { + logger.info(`[Bracket] Contest ${contestId} fully completed.`); + await runOrDeferEffect(deferredEffects, async () => { + const redisClient = await getRedis(); + await cleanupContestRedisKeys(redisClient, contestId); + }); + } } else { - logger.info(`[Bracket] Contest ${contestId} fully completed.`); - await runOrDeferEffect(deferredEffects, async () => { - const redisClient = await getRedis(); - const keys = await redisClient.keys(`contest:${contestId}:*`); - if (keys.length > 0) await redisClient.del(keys); + const nextRound = await ContestRound.findOne({ + contestId, + roundNumber: roundNumber + 1, }); + if (nextRound) { + nextRound.status = "active"; + await nextRound.save(); + logger.info( + `[Bracket] Round ${roundNumber} complete. Advancing to round ${roundNumber + 1}`, + ); + } else { + logger.info(`[Bracket] Contest ${contestId} fully completed.`); + await runOrDeferEffect(deferredEffects, async () => { + const redisClient = await getRedis(); + await cleanupContestRedisKeys(redisClient, contestId); + }); + } } } finally { if (redis && lockAcquired) await redis.del(lockKey); @@ -775,6 +1530,7 @@ export async function getBracketSnapshot( const contest = await ContestMatch.findById(contestId); if (!contest) throw new Error("Contest not found"); + const isDoubleElim = contest.bracketSettings?.type === "double_elimination"; const rounds = await ContestRound.find({ contestId }).sort({ roundNumber: 1, }); @@ -786,6 +1542,13 @@ export async function getBracketSnapshot( 10, ); + let upperRoundsCount: number | undefined; + let lowerRoundsCount: number | undefined; + if (isDoubleElim) { + upperRoundsCount = rounds.filter((r) => r.bracketType === "upper").length; + lowerRoundsCount = rounds.filter((r) => r.bracketType === "lower").length; + } + const nodes: BracketNode[] = []; for (const round of rounds) { @@ -845,10 +1608,15 @@ export async function getBracketSnapshot( status = "waiting"; } + const nodeBracketType: BracketType = + (round.bracketType as BracketType) || + parseBracketPosition(room.bracketPosition || "").stage; + nodes.push({ roomId: toStr(room._id), roundNumber: round.roundNumber, matchIndex: rooms.indexOf(room), + bracketType: nodeBracketType, teams: teamIds, teamNames, teamImages, @@ -860,7 +1628,15 @@ export async function getBracketSnapshot( } } - return { contestId, currentRound, totalRounds, nodes }; + return { + contestId, + bracketType: isDoubleElim ? "double_elimination" : "single_elimination", + currentRound, + totalRounds, + upperRounds: upperRoundsCount, + lowerRounds: lowerRoundsCount, + nodes, + }; } export async function processWalkover( diff --git a/src/lib/contests/dtos.ts b/src/lib/contests/dtos.ts index cee695ca..68373249 100644 --- a/src/lib/contests/dtos.ts +++ b/src/lib/contests/dtos.ts @@ -19,6 +19,8 @@ export type ContestPresetDto = { rating?: number; problemId?: string; roundNumber?: number; + points?: number; + timeLimitMinutes?: number; }>; fineTunedProblemCount?: number; archived?: boolean; diff --git a/src/lib/contests/runtime.ts b/src/lib/contests/runtime.ts index 027c28f6..e75954d5 100644 --- a/src/lib/contests/runtime.ts +++ b/src/lib/contests/runtime.ts @@ -75,6 +75,8 @@ export const contestRoomStateSchema = z type: z.string().optional(), startTime: z.string().optional(), timeLimit: z.string().optional(), + problemTimeLimit: z.string().optional(), + currentProblemStartTime: z.string().optional(), currentProblem: z.string().optional(), contestId: z.string().optional(), }) diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index bbe0cce8..d367a032 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -467,6 +467,9 @@ export const cfSyncWorker = new Worker( newProblemIndex, JSON.stringify(nextProblem), ); + await redis.hSet(`room:${roomId}:state`, { + currentProblemStartTime: Date.now().toString(), + }); await publishRoom(roomId, { type: "room.advance", diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts index 64448e08..78015905 100644 --- a/src/lib/workers/reconciliationWorker.ts +++ b/src/lib/workers/reconciliationWorker.ts @@ -541,6 +541,8 @@ export const reconciliationWorker = new Worker< problemId: string; name: string; rating?: number; + points?: number; + timeLimitMinutes?: number; }> = []; if (contest.problemSelectionMode === "test") { availableProblems = [ @@ -565,12 +567,16 @@ export const reconciliationWorker = new Worker< problemId: q.problemId, name: q.name, rating: q.rating, + points: slot.points, + timeLimitMinutes: slot.timeLimitMinutes, }); } else { availableProblems.push({ problemId: slot.problemId, name: `Problem ${slot.problemId}`, rating: 0, + points: slot.points, + timeLimitMinutes: slot.timeLimitMinutes, }); } } @@ -579,6 +585,8 @@ export const reconciliationWorker = new Worker< problemId: string; name: string; rating?: number; + points?: number; + timeLimitMinutes?: number; }>([ { $match: { @@ -635,7 +643,10 @@ export const reconciliationWorker = new Worker< problemId: problem.problemId, name: problem.name, rating: problem.rating, - points: Math.floor((problem.rating || 1000) / 10), + points: + problem.points ?? + (problem.rating ? Math.floor(problem.rating / 10) : 100), + timeLimitMinutes: problem.timeLimitMinutes, })), }); @@ -664,7 +675,10 @@ export const reconciliationWorker = new Worker< problemId: problem.problemId, name: problem.name, rating: problem.rating, - points: Math.floor((problem.rating || 1000) / 10), + points: + problem.points ?? + (problem.rating ? Math.floor(problem.rating / 10) : 100), + timeLimitMinutes: problem.timeLimitMinutes, revealedAt: null, }), ); @@ -673,14 +687,23 @@ export const reconciliationWorker = new Worker< await redis.rPush(`room:${newRoomId}:problems`, redisProblems); } + const durationSec = contest.overallDurationMinutes + ? contest.overallDurationMinutes * 60 + : contest.durationSeconds || 3600; + const stateObj: Record = { status: "pending", type: contest.mode || "blitz", startTime: "", // Empty for now, set when all ready - timeLimit: (contest.durationSeconds || 3600).toString(), + timeLimit: durationSec.toString(), contestId: contestId.toString(), readyCount: 0, }; + if (contest.perProblemDurationMinutes) { + stateObj.problemTimeLimit = ( + contest.perProblemDurationMinutes * 60 + ).toString(); + } if (contest.mode !== "arena") { stateObj.currentProblem = 0; } diff --git a/src/models/ContestMatch.ts b/src/models/ContestMatch.ts index b3bce7d2..1510cb10 100644 --- a/src/models/ContestMatch.ts +++ b/src/models/ContestMatch.ts @@ -5,6 +5,8 @@ export interface IProblemSlot { rating?: number; problemId?: string; roundNumber?: number; + points?: number; + timeLimitMinutes?: number; } export interface IRegistration { @@ -22,6 +24,7 @@ export interface IRegistrationSettings { } export interface IBracketSettings { + type?: "single_elimination" | "double_elimination"; thirdPlacePlayoff: boolean; seedingMethod: "cf_rating" | "manual"; } @@ -33,6 +36,8 @@ export interface IContestMatch extends Document { startTime?: Date; endTime?: Date; durationSeconds?: number; + overallDurationMinutes?: number; + perProblemDurationMinutes?: number; format: "1v1" | "solo-tournament" | "team-tournament" | "bracket"; mode: "blitz" | "arena" | "knockout"; status: "draft" | "registration" | "provisioning" | "active" | "completed"; @@ -62,6 +67,8 @@ const ProblemSlotSchema = new Schema({ rating: { type: Number }, problemId: { type: String }, roundNumber: { type: Number }, + points: { type: Number, default: 100 }, + timeLimitMinutes: { type: Number }, }); const RegistrationSchema = new Schema({ @@ -79,6 +86,11 @@ const RegistrationSettingsSchema = new Schema({ }); const BracketSettingsSchema = new Schema({ + type: { + type: String, + enum: ["single_elimination", "double_elimination"], + default: "single_elimination", + }, thirdPlacePlayoff: { type: Boolean, default: false }, seedingMethod: { type: String, @@ -100,6 +112,8 @@ const ContestMatchSchema = new Schema( startTime: { type: Date }, endTime: { type: Date }, durationSeconds: { type: Number }, + overallDurationMinutes: { type: Number }, + perProblemDurationMinutes: { type: Number }, format: { type: String, required: true, diff --git a/src/models/ContestProblemSet.ts b/src/models/ContestProblemSet.ts index b02b32ea..12e44967 100644 --- a/src/models/ContestProblemSet.ts +++ b/src/models/ContestProblemSet.ts @@ -7,6 +7,7 @@ export interface ISelectedProblem { rating?: number; url?: string; points: number; + timeLimitMinutes?: number; } export interface IContestProblemSet extends Document { @@ -24,6 +25,7 @@ const SelectedProblemSchema = new Schema({ rating: { type: Number }, url: { type: String }, points: { type: Number, required: true, default: 100 }, + timeLimitMinutes: { type: Number }, }); const ContestProblemSetSchema = new Schema( diff --git a/src/models/ContestRound.ts b/src/models/ContestRound.ts index 1bca525a..c16f156f 100644 --- a/src/models/ContestRound.ts +++ b/src/models/ContestRound.ts @@ -7,6 +7,8 @@ export interface IContestRound extends Document { status: "pending" | "active" | "completed"; rooms: mongoose.Types.ObjectId[]; bracketLevel?: string; + bracketType?: "upper" | "lower" | "grand_final"; + bracketRoundNumber?: number; createdAt: Date; updatedAt: Date; } @@ -29,6 +31,12 @@ const ContestRoundSchema = new Schema( }, rooms: [{ type: Schema.Types.ObjectId, ref: "ContestRoom" }], bracketLevel: { type: String }, + bracketType: { + type: String, + enum: ["upper", "lower", "grand_final"], + default: "upper", + }, + bracketRoundNumber: { type: Number }, }, { timestamps: true }, ); diff --git a/src/types/bracket.test.ts b/src/types/bracket.test.ts new file mode 100644 index 00000000..603908f5 --- /dev/null +++ b/src/types/bracket.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; + +import { + getRoundName, + nextPowerOf2, + parseBracketPosition, + snakeSeed, +} from "@/types/bracket"; + +describe("bracket helpers & double elimination structures", () => { + describe("parseBracketPosition", () => { + it("handles legacy two-part bracket position strings by defaulting to upper bracket", () => { + const pos = parseBracketPosition("0-1"); + expect(pos).toEqual({ + stage: "upper", + roundIndex: 0, + matchIndex: 1, + }); + }); + + it("parses upper stage positions correctly", () => { + const pos = parseBracketPosition("upper-2-3"); + expect(pos).toEqual({ + stage: "upper", + roundIndex: 2, + matchIndex: 3, + }); + }); + + it("parses lower stage positions correctly", () => { + const pos = parseBracketPosition("lower-1-0"); + expect(pos).toEqual({ + stage: "lower", + roundIndex: 1, + matchIndex: 0, + }); + }); + + it("parses grand_final stage positions correctly", () => { + const pos = parseBracketPosition("grand_final-0-0"); + expect(pos).toEqual({ + stage: "grand_final", + roundIndex: 0, + matchIndex: 0, + }); + }); + }); + + describe("getRoundName", () => { + it("returns standard single elimination round names", () => { + expect(getRoundName(4, 4)).toBe("Final"); + expect(getRoundName(3, 4)).toBe("Semi-Finals"); + expect(getRoundName(2, 4)).toBe("Quarter-Finals"); + expect(getRoundName(1, 4)).toBe("Round of 16"); + }); + + it("returns lower bracket specific round names", () => { + expect(getRoundName(4, 4, "lower")).toBe("Lower Final"); + expect(getRoundName(3, 4, "lower")).toBe("Lower Semi-Finals"); + expect(getRoundName(2, 4, "lower")).toBe("Lower Round 2"); + expect(getRoundName(1, 4, "lower")).toBe("Lower Round 1"); + }); + + it("returns Grand Final name", () => { + expect(getRoundName(1, 1, "grand_final")).toBe("Grand Final"); + }); + }); + + describe("snakeSeed & nextPowerOf2", () => { + it("computes next power of 2 correctly", () => { + expect(nextPowerOf2(1)).toBe(2); + expect(nextPowerOf2(2)).toBe(2); + expect(nextPowerOf2(3)).toBe(4); + expect(nextPowerOf2(4)).toBe(4); + expect(nextPowerOf2(5)).toBe(8); + expect(nextPowerOf2(15)).toBe(16); + }); + + it("seeds teams in snake order", () => { + const teams = [ + { teamId: "t1", seed: 1 }, + { teamId: "t2", seed: 2 }, + { teamId: "t3", seed: 3 }, + { teamId: "t4", seed: 4 }, + ]; + const seeded = snakeSeed(teams); + expect(seeded.map((t) => t.teamId)).toEqual(["t1", "t4", "t2", "t3"]); + }); + }); + + describe("double elimination round and match math", () => { + it.each([ + [4, 2, 2, 5], + [8, 3, 4, 8], + [16, 4, 6, 11], + ])( + "for size %i calculates Upper=%i, Lower=%i, Total=%i rounds", + (size, expectedUpper, expectedLower, expectedTotal) => { + const totalUpper = Math.log2(size); + const totalLower = 2 * (totalUpper - 1); + const totalRounds = totalUpper + totalLower + 1; + + expect(totalUpper).toBe(expectedUpper); + expect(totalLower).toBe(expectedLower); + expect(totalRounds).toBe(expectedTotal); + }, + ); + + it("calculates lower bracket matches per round correctly for size 8", () => { + const size = 8; + const totalLowerRounds = 2 * (Math.log2(size) - 1); // 4 + const matchesPerLowerRound: number[] = []; + + let currentLowerMatches = size / 4; // 2 + for (let l = 0; l < totalLowerRounds; l++) { + if (l > 0 && l % 2 === 0) { + currentLowerMatches = currentLowerMatches / 2; + } + matchesPerLowerRound.push(currentLowerMatches); + } + + // Expected for size 8: Round 1 (2), Round 2 (2), Round 3 (1), Round 4 (1) + expect(matchesPerLowerRound).toEqual([2, 2, 1, 1]); + }); + + it("calculates lower bracket matches per round correctly for size 16", () => { + const size = 16; + const totalLowerRounds = 2 * (Math.log2(size) - 1); // 6 + const matchesPerLowerRound: number[] = []; + + let currentLowerMatches = size / 4; // 4 + for (let l = 0; l < totalLowerRounds; l++) { + if (l > 0 && l % 2 === 0) { + currentLowerMatches = currentLowerMatches / 2; + } + matchesPerLowerRound.push(currentLowerMatches); + } + + // Expected for size 16: 4, 4, 2, 2, 1, 1 + expect(matchesPerLowerRound).toEqual([4, 4, 2, 2, 1, 1]); + }); + }); +}); diff --git a/src/types/bracket.ts b/src/types/bracket.ts index d2c9d81d..d68d9317 100644 --- a/src/types/bracket.ts +++ b/src/types/bracket.ts @@ -1,9 +1,11 @@ export type BracketPosition = string; +export type BracketType = "upper" | "lower" | "grand_final"; export type BracketNode = { roomId: string; roundNumber: number; matchIndex: number; + bracketType?: BracketType; teams: [string | null, string | null]; teamNames: [string | null, string | null]; teamImages?: [string | null, string | null]; @@ -15,8 +17,11 @@ export type BracketNode = { export type BracketSnapshot = { contestId: string; + bracketType?: "single_elimination" | "double_elimination"; currentRound: number; totalRounds: number; + upperRounds?: number; + lowerRounds?: number; nodes: BracketNode[]; }; @@ -30,11 +35,41 @@ export const ROUND_NAMES: Record = { 7: "Round of 128", }; -export function getRoundName(roundNumber: number, totalRounds: number): string { +export function parseBracketPosition(pos: string): { + stage: BracketType; + roundIndex: number; + matchIndex: number; +} { + const parts = pos.split("-"); + if (parts.length === 3) { + return { + stage: parts[0] as BracketType, + roundIndex: parseInt(parts[1], 10), + matchIndex: parseInt(parts[2], 10), + }; + } + return { + stage: "upper", + roundIndex: parseInt(parts[0], 10), + matchIndex: parseInt(parts[1], 10), + }; +} + +export function getRoundName( + roundNumber: number, + totalRounds: number, + bracketType?: BracketType, +): string { + if (bracketType === "grand_final") return "Grand Final"; + if (bracketType === "lower") { + if (roundNumber === totalRounds) return "Lower Final"; + if (roundNumber === totalRounds - 1) return "Lower Semi-Finals"; + return `Lower Round ${roundNumber}`; + } if (roundNumber === totalRounds) return "Final"; if (roundNumber === totalRounds - 1) return "Semi-Finals"; if (roundNumber === totalRounds - 2) return "Quarter-Finals"; - const participants = Math.pow(2, roundNumber + 1); + const participants = Math.pow(2, totalRounds - roundNumber + 1); return `Round of ${participants}`; }