Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/db/migrations/1773501626133-direct_matches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class DirectMatches1773501626133 implements MigrationInterface {
name = 'DirectMatches1773501626133'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "teams" ADD "allowChallenges" boolean NOT NULL DEFAULT true`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "teams" DROP COLUMN "allowChallenges"`);
}

}
129 changes: 111 additions & 18 deletions api/src/match/match.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<opponentId, lastMatchTime>
const lastPlayedWith = new Map<string, Map<string, Date>>();
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<string, Date>();
lastPlayedWith.set(t1, t1Map);
}
let t2Map = lastPlayedWith.get(t2);
if (!t2Map) {
t2Map = new Map<string, Date>();
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 });
Comment on lines +141 to +153
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does not make any sense to use if the teams information was updated in the last 10 minutes that they should be chosen more over others. It should be if the team themselfs challenged someone else or if they went in the queue. And the scoring should dynamic not fixed based on 10 minutes

Copy link
Copy Markdown
Member Author

@Peu77 Peu77 Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no it doesn't make sense

Copy link
Copy Markdown
Member Author

@Peu77 Peu77 Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agentic ai slop🚀🚀🚀🚀🚀

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and? 😂

}
}

// Sort pairings by score descending
pairings.sort((a, b) => b.score - a.score);

const matchedTeamIds = new Set<string>();
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);
}
}),
);
Expand Down
3 changes: 3 additions & 0 deletions api/src/team/entities/team.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
38 changes: 38 additions & 0 deletions api/src/team/team.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
62 changes: 61 additions & 1 deletion api/src/team/team.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -589,6 +589,28 @@ export class TeamService {
return this.teamRepository.update(teamId, { queueScore: score });
}

async getTeamsEligibleForMatchmaking(eventId: string): Promise<TeamEntity[]> {
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<TeamEntity[]> {
return this.teamRepository.find({
where: {
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions frontend/app/actions/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface Team {
createdAt?: Date;
updatedAt?: Date;
membersCount?: number;
allowChallenges: boolean;
}

export interface TeamMember {
Expand Down Expand Up @@ -80,6 +81,7 @@ export async function getTeamById(teamId: string): Promise<Team | null> {
createdAt: team.createdAt,
inQueue: team.inQueue,
updatedAt: team.updatedAt,
allowChallenges: team.allowChallenges,
}
: null;
}
Expand Down Expand Up @@ -107,6 +109,7 @@ export async function getMyEventTeam(eventId: string): Promise<Team | null> {
inQueue: team.inQueue,
createdAt: team.createdAt,
updatedAt: team.updatedAt,
allowChallenges: team.allowChallenges,
};
}

Expand Down Expand Up @@ -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<ServerActionResponse<void>> {
return await handleError(
axiosInstance.put(`team/event/${eventId}/allowChallenges/toggle`),
);
}

export async function challengeTeam(eventId: string, targetTeamId: string): Promise<ServerActionResponse<void>> {
return await handleError(
axiosInstance.post(`team/event/${eventId}/challenge/${targetTeamId}`),
);
}
58 changes: 58 additions & 0 deletions frontend/app/events/[id]/teams/[teamId]/ChallengeButton.tsx
Original file line number Diff line number Diff line change
@@ -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<ChallengeButtonProps>) {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(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);
Comment on lines +31 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n "frontend/app/events/[id]/teams/[teamId]/ChallengeButton.tsx"

Repository: 42core-team/website

Length of output: 2045


🏁 Script executed:

# Check for any other similar patterns in the codebase that might inform the correctness of the suggestion
find frontend -name "*.tsx" -type f | head -10 | xargs grep -l "finally.*setIsPending" 2>/dev/null | head -5

Repository: 42core-team/website

Length of output: 45


🏁 Script executed:

# Check what challengeTeam action does and whether it's a server action
fd -t f "challengeTeam" frontend --exec grep -l "challengeTeam" {} \; 2>/dev/null | head -5

Repository: 42core-team/website

Length of output: 45


🏁 Script executed:

# Find the challengeTeam action to understand its behavior
find frontend -name "*.ts" -o -name "*.tsx" | xargs grep -l "export.*challengeTeam" 2>/dev/null

Repository: 42core-team/website

Length of output: 91


🏁 Script executed:

# Check the app/actions directory structure
ls -la "frontend/app/actions/" 2>/dev/null || echo "actions dir check"

Repository: 42core-team/website

Length of output: 861


🏁 Script executed:

# Search for the actual challengeTeam implementation
rg "export.*challengeTeam|function challengeTeam" frontend/app/actions/ -A 5 2>/dev/null

Repository: 42core-team/website

Length of output: 431


Don't clear isPending on the success path.

finally makes the button clickable again before the route transition finishes, so a slow navigation can send challengeTeam() twice. For this write flow, only clear the pending state when the challenge fails.

💡 Proposed fix
   try {
     const result = await challengeTeam(eventId, targetTeamId);
     if (isActionError(result)) {
       setError(result.error);
+      setIsPending(false);
     } else {
       router.push(`/events/${eventId}/queue`);
     }
   } catch (e: any) {
     setError(e.message || "An error occurred");
-  } finally {
     setIsPending(false);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/events/`[id]/teams/[teamId]/ChallengeButton.tsx around lines 31
- 40, The current finally block clears isPending which allows the button to be
re-clicked before navigation completes; update the flow so setIsPending(false)
is only called on failure paths: when isActionError(result) is true (inside that
branch where you call setError(result.error)) and in the catch block (where you
call setError(e.message || "...")), but do NOT call setIsPending(false) on the
success branch that calls router.push(`/events/${eventId}/queue`) — leave
isPending true during navigation to prevent duplicate challengeTeam() calls.
Ensure this change touches the async handler that calls challengeTeam,
isActionError, setError, setIsPending and router.push in ChallengeButton.tsx.

}
};

Comment on lines +23 to +43
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use TanStack Query here 😛

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😂😂😂

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wie du mir, so ich dir 😛

return (
<div className="flex flex-col items-end gap-1">
<Button
variant="outline"
onClick={handleChallenge}
disabled={isPending || disabled}
className="flex items-center gap-2"
>
<Swords className="size-4" />
{isPending ? "Challenging..." : `Challenge ${targetTeamName}`}
</Button>
{error && <span className="text-xs text-destructive">{error}</span>}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make challenge failures announce themselves.

This error node is inserted after the click, but a plain span is not reliably announced by screen readers. Add a live region so failed challenges are not silent.

♿ Proposed fix
-      {error && <span className="text-xs text-destructive">{error}</span>}
+      {error && (
+        <span role="alert" className="text-xs text-destructive">
+          {error}
+        </span>
+      )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{error && <span className="text-xs text-destructive">{error}</span>}
{error && (
<span role="alert" className="text-xs text-destructive">
{error}
</span>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/events/`[id]/teams/[teamId]/ChallengeButton.tsx at line 55, The
error message rendered in ChallengeButton.tsx using the plain span for {error}
is not reliably announced by screen readers; change the error node to be a live
region (e.g., use a container with aria-live="assertive" or "polite" and/or
role="alert") so failed challenges are announced. Locate the JSX that renders
{error} in the ChallengeButton component and replace the plain <span> with an
accessible live region element (keeping the same styling classes like "text-xs
text-destructive") using aria-live="assertive" or role="alert" to ensure screen
readers announce the error.

</div>
);
}
Loading
Loading