diff --git a/api/db/migrations/1773501626133-direct_matches.ts b/api/db/migrations/1773501626133-direct_matches.ts new file mode 100644 index 00000000..dda808da --- /dev/null +++ b/api/db/migrations/1773501626133-direct_matches.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DirectMatches1773501626133 implements MigrationInterface { + name = 'DirectMatches1773501626133' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "teams" ADD "allowChallenges" boolean NOT NULL DEFAULT true`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "teams" DROP COLUMN "allowChallenges"`); + } + +} diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index 4e52c053..55301cfa 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -65,31 +65,124 @@ export class MatchService { const events = await this.eventService.getAllEventsForQueue(); await Promise.all( events.map(async (event) => { - let teamsInQueue = await this.teamService.getTeamsInQueue( - event.id, - ); - while (teamsInQueue.length >= 2) { - const team1 = - teamsInQueue[Math.floor(Math.random() * teamsInQueue.length)]; - teamsInQueue = teamsInQueue.filter( - (team) => team.id !== team1.id, - ); - const team2 = - teamsInQueue[Math.floor(Math.random() * teamsInQueue.length)]; - teamsInQueue = teamsInQueue.filter( - (team) => team.id !== team2.id, - ); + const eligibleTeams = + await this.teamService.getTeamsEligibleForMatchmaking(event.id); + + if (eligibleTeams.length < 2) return; + + // 1. Fetch recent matches for all eligible teams to calculate "recency penalty" + const recentMatches = await this.matchRepository.find({ + where: { + phase: MatchPhase.QUEUE, + state: MatchState.FINISHED, + teams: { + id: In(eligibleTeams.map((t) => t.id)), + }, + }, + relations: { teams: true }, + order: { createdAt: "DESC" }, + take: 100, // Look at last 100 queue matches in this event + }); + + // Map to store recent opponents for each team + // teamId -> Map + const lastPlayedWith = new Map>(); + recentMatches.forEach((m) => { + const t1 = m.teams[0].id; + const t2 = m.teams[1].id; + const time = m.createdAt; + + let t1Map = lastPlayedWith.get(t1); + if (!t1Map) { + t1Map = new Map(); + lastPlayedWith.set(t1, t1Map); + } + let t2Map = lastPlayedWith.get(t2); + if (!t2Map) { + t2Map = new Map(); + lastPlayedWith.set(t2, t2Map); + } + + if (!t1Map.has(t2)) t1Map.set(t2, time); + if (!t2Map.has(t1)) t2Map.set(t1, time); + }); + + const pairings: Array<{ + t1: TeamEntity; + t2: TeamEntity; + score: number; + }> = []; + + for (let i = 0; i < eligibleTeams.length; i++) { + for (let j = i + 1; j < eligibleTeams.length; j++) { + const t1 = eligibleTeams[i]; + const t2 = eligibleTeams[j]; + + // If both teams don't have inQueue = true, they can't match each other automatically + // One of them MUST be in queue for a match to be triggered automatically + if (!t1.inQueue && !t2.inQueue) continue; + + let score = 1000; // Base score + + // 1. ELO Similarity (Priority) + const eloDiff = Math.abs(t1.queueScore - t2.queueScore); + score -= eloDiff * 2; + + // 2. Recent Opponent Penalty (Deprioritize) + const lastMatchTime = lastPlayedWith.get(t1.id)?.get(t2.id); + if (lastMatchTime) { + const diffMs = Date.now() - lastMatchTime.getTime(); + const hoursSince = diffMs / (1000 * 60 * 60); + // Penalty decays over time. Max penalty if just played, 0 after 24 hours. + const penalty = Math.max(0, 1000 * (1 - hoursSince / 24)); + score -= penalty; + } + + // 3. Activity Boost (Prioritize active players) + // Use updatedAt as a proxy for recent queue activity/challenges toggle + const now = Date.now(); + const t1Activity = + (now - t1.updatedAt.getTime()) / (1000 * 60); // minutes + const t2Activity = + (now - t2.updatedAt.getTime()) / (1000 * 60); // minutes + + // Boost if they were active in the last 10 minutes + if (t1Activity < 10) score += 50; + if (t2Activity < 10) score += 50; + + pairings.push({ t1, t2, score }); + } + } + + // Sort pairings by score descending + pairings.sort((a, b) => b.score - a.score); + + const matchedTeamIds = new Set(); + for (const pairing of pairings) { + if ( + matchedTeamIds.has(pairing.t1.id) || + matchedTeamIds.has(pairing.t2.id) + ) + continue; + this.logger.log( - `Creating queue match for teams ${team1.name} and ${team2.name} in event ${event.name}.`, + `Creating dynamic queue match for teams ${pairing.t1.name} and ${pairing.t2.name} in event ${event.name} (Score: ${pairing.score.toFixed(2)}).`, ); + const match = await this.createMatch( - [team1.id, team2.id], + [pairing.t1.id, pairing.t2.id], 0, MatchPhase.QUEUE, ); await this.startMatch(match.id); - await this.teamService.removeFromQueue(team1.id); - await this.teamService.removeFromQueue(team2.id); + + if (pairing.t1.inQueue) + await this.teamService.removeFromQueue(pairing.t1.id); + if (pairing.t2.inQueue) + await this.teamService.removeFromQueue(pairing.t2.id); + + matchedTeamIds.add(pairing.t1.id); + matchedTeamIds.add(pairing.t2.id); } }), ); diff --git a/api/src/team/entities/team.entity.ts b/api/src/team/entities/team.entity.ts index 9547e4bc..f7c2d7ba 100644 --- a/api/src/team/entities/team.entity.ts +++ b/api/src/team/entities/team.entity.ts @@ -61,6 +61,9 @@ export class TeamEntity { @Column({ default: false }) hadBye: boolean; + @Column({ default: true }) + allowChallenges: boolean; + @JoinTable({ name: "teams_invites_users" }) @ManyToMany(() => UserEntity, (user) => user.teamInvites) teamInvites: UserEntity[]; diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index aad810b5..5d737d86 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -307,4 +307,42 @@ export class TeamController { async getQueueState(@Team() team: TeamEntity) { return this.teamService.getQueueState(team.id); } + + @UseGuards(JwtAuthGuard, MyTeamGuards) + @Put(`event/:${EVENT_ID_PARAM}/allowChallenges/toggle`) + async toggleAllowChallenges(@Team() team: TeamEntity, @UserId() userId: string) { + this.logger.log({ + action: "attempt_toggle_allow_challenges", + teamId: team.id, + userId, + }); + return this.teamService.toggleAllowChallenges(team.id); + } + + @UseGuards(JwtAuthGuard, MyTeamGuards) + @Post(`event/:${EVENT_ID_PARAM}/challenge/:targetTeamId`) + async challengeTeam( + @EventId eventId: string, + @Team() challengerTeam: TeamEntity, + @Param("targetTeamId", ParseUUIDPipe) targetTeamId: string, + @UserId() userId: string, + ) { + if (!(await this.eventService.hasEventStartedForTeam(challengerTeam.id))) { + throw new BadRequestException("The event has not started yet."); + } + + this.logger.log({ + action: "attempt_challenge_team", + challengerTeamId: challengerTeam.id, + targetTeamId, + userId, + eventId, + }); + + return this.teamService.challengeTeam( + eventId, + challengerTeam.id, + targetTeamId, + ); + } } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 1d17af89..d0fd155e 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -21,7 +21,7 @@ import { FindOptionsRelations } from "typeorm/find-options/FindOptionsRelations" import { MatchService } from "../match/match.service"; import { Cron, CronExpression } from "@nestjs/schedule"; import { LockKeys } from "../constants"; -import { MatchEntity } from "../match/entites/match.entity"; +import { MatchEntity, MatchPhase, MatchState } from "../match/entites/match.entity"; @Injectable() export class TeamService { @@ -589,6 +589,28 @@ export class TeamService { return this.teamRepository.update(teamId, { queueScore: score }); } + async getTeamsEligibleForMatchmaking(eventId: string): Promise { + return this.teamRepository + .createQueryBuilder("team") + .where("team.eventId = :eventId", { eventId }) + .andWhere("team.repo IS NOT NULL") + .andWhere("(team.inQueue = true OR team.allowChallenges = true)") + .andWhere((qb) => { + const subQuery = qb + .subQuery() + .select("match.id") + .from(MatchEntity, "match") + .innerJoin("match.teams", "innerTeam") + .where("innerTeam.id = team.id") + .andWhere("match.state = :inProgress", { + inProgress: MatchState.IN_PROGRESS, + }) + .getQuery(); + return "NOT EXISTS " + subQuery; + }) + .getMany(); + } + async getTeamsInQueue(eventId: string): Promise { return this.teamRepository.find({ where: { @@ -637,6 +659,44 @@ export class TeamService { return this.teamRepository.update(teamId, { inQueue: false }); } + async toggleAllowChallenges(teamId: string) { + const team = await this.getTeamById(teamId); + return this.teamRepository.update(teamId, { + allowChallenges: !team.allowChallenges, + }); + } + + async challengeTeam( + eventId: string, + challengerTeamId: string, + targetTeamId: string, + ) { + const targetTeam = await this.getTeamById(targetTeamId, { + event: true + }); + if (!targetTeam || targetTeam.event.id !== eventId) { + throw new BadRequestException("Target team not found in this event."); + } + + if (!targetTeam.allowChallenges) { + throw new BadRequestException("Target team does not allow challenges."); + } + + if (challengerTeamId === targetTeamId) { + throw new BadRequestException("You cannot challenge your own team."); + } + + const match = await this.matchService.createMatch( + [challengerTeamId, targetTeamId], + 0, + MatchPhase.QUEUE, + ); + + await this.matchService.startMatch(match.id); + + return match; + } + async unlockTeamsForEvent(eventId: string) { const teams = await this.dataSource.transaction(async (entityManager) => { const teamRepository = entityManager.getRepository(TeamEntity); diff --git a/frontend/app/actions/team.ts b/frontend/app/actions/team.ts index 5cc12f31..2069ec44 100644 --- a/frontend/app/actions/team.ts +++ b/frontend/app/actions/team.ts @@ -20,6 +20,7 @@ export interface Team { createdAt?: Date; updatedAt?: Date; membersCount?: number; + allowChallenges: boolean; } export interface TeamMember { @@ -80,6 +81,7 @@ export async function getTeamById(teamId: string): Promise { createdAt: team.createdAt, inQueue: team.inQueue, updatedAt: team.updatedAt, + allowChallenges: team.allowChallenges, } : null; } @@ -107,6 +109,7 @@ export async function getMyEventTeam(eventId: string): Promise { inQueue: team.inQueue, createdAt: team.createdAt, updatedAt: team.updatedAt, + allowChallenges: team.allowChallenges, }; } @@ -241,5 +244,18 @@ export async function getTeamsForEventTable( queueScore: team.queueScore ?? 0, createdAt: team.createdAt, updatedAt: team.updatedAt, + allowChallenges: team.allowChallenges, })); } + +export async function toggleAllowChallenges(eventId: string): Promise> { + return await handleError( + axiosInstance.put(`team/event/${eventId}/allowChallenges/toggle`), + ); +} + +export async function challengeTeam(eventId: string, targetTeamId: string): Promise> { + return await handleError( + axiosInstance.post(`team/event/${eventId}/challenge/${targetTeamId}`), + ); +} diff --git a/frontend/app/events/[id]/teams/[teamId]/ChallengeButton.tsx b/frontend/app/events/[id]/teams/[teamId]/ChallengeButton.tsx new file mode 100644 index 00000000..2a7ff881 --- /dev/null +++ b/frontend/app/events/[id]/teams/[teamId]/ChallengeButton.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { challengeTeam } from "@/app/actions/team"; +import { Button } from "@/components/ui/button"; +import { Swords } from "lucide-react"; +import { isActionError } from "@/app/actions/errors"; + +interface ChallengeButtonProps { + eventId: string; + targetTeamId: string; + targetTeamName: string; + disabled?: boolean; +} + +export default function ChallengeButton({ + eventId, + targetTeamId, + targetTeamName, + disabled = false, +}: Readonly) { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + const handleChallenge = async () => { + setIsPending(true); + setError(null); + try { + const result = await challengeTeam(eventId, targetTeamId); + if (isActionError(result)) { + setError(result.error); + } else { + router.push(`/events/${eventId}/queue`); + } + } catch (e: any) { + setError(e.message || "An error occurred"); + } finally { + setIsPending(false); + } + }; + + return ( +
+ + {error && {error}} +
+ ); +} diff --git a/frontend/app/events/[id]/teams/[teamId]/page.tsx b/frontend/app/events/[id]/teams/[teamId]/page.tsx index 9ed6f2de..1b3e2a6a 100644 --- a/frontend/app/events/[id]/teams/[teamId]/page.tsx +++ b/frontend/app/events/[id]/teams/[teamId]/page.tsx @@ -2,10 +2,11 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import React from "react"; import { isActionError } from "@/app/actions/errors"; -import { getTeamById, getTeamMembers } from "@/app/actions/team"; +import { getMyEventTeam, getTeamById, getTeamMembers } from "@/app/actions/team"; import { getMatchesForTeam } from "@/app/actions/tournament"; import { Card } from "@/components/ui/card"; import BackButton from "./BackButton"; +import ChallengeButton from "./ChallengeButton"; import TeamMatchHistory from "./TeamMatchHistory"; import TeamUserTable from "./TeamUserTable"; @@ -42,25 +43,39 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) { const members = await getTeamMembers(teamId); const teamInfo = await getTeamById(teamId); + const myTeam = await getMyEventTeam(eventId); + if (!teamInfo || !members) { notFound(); } + const isMyTeam = myTeam?.id === teamId; + const canChallenge = !isMyTeam && teamInfo.allowChallenges; + const matches = await getMatchesForTeam(teamId); return (
-
-
- +
+
+
+ +
+

+ Team + {" "} + {teamInfo.name} +

-

- Team - {" "} - {teamInfo.name} -

+ {canChallenge && ( + + )}
diff --git a/frontend/components/team/TeamInfoSection.tsx b/frontend/components/team/TeamInfoSection.tsx index 12294248..1cbb40ec 100644 --- a/frontend/components/team/TeamInfoSection.tsx +++ b/frontend/components/team/TeamInfoSection.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useState } from "react"; +import { toggleAllowChallenges } from "@/app/actions/team"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -19,7 +20,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import TeamInviteModal from "./TeamInviteModal"; @@ -44,11 +47,21 @@ export function TeamInfoSection({ const [isOpen, setIsOpen] = useState(false); const [leaveError, setLeaveError] = useState(null); const [isLeaveDialogOpen, setIsLeaveDialogOpen] = useState(false); + const [allowChallenges, setAllowChallenges] = useState(myTeam.allowChallenges); const getRepoUrl = () => { return `https://github.com/${githubOrg}/${myTeam.repo}`; }; + const handleToggleAllowChallenges = async () => { + const previous = allowChallenges; + setAllowChallenges(!previous); + const result = await toggleAllowChallenges(eventId); + if (result && "error" in result) { + setAllowChallenges(previous); + } + }; + const handleConfirmLeave = async () => { setLeaveError(null); const success = await onLeaveTeam(); @@ -115,6 +128,14 @@ export function TeamInfoSection({ {new Date(myTeam.updatedAt || "").toLocaleDateString()}

+
+ + +
{/* Team Members Section */}