-
Notifications
You must be signed in to change notification settings - Fork 4
558 rework queue direct matches #582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"`); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no it doesn't make sense
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agentic ai slop🚀🚀🚀🚀🚀
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| }), | ||
| ); | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 -5Repository: 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 -5Repository: 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/nullRepository: 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/nullRepository: 42core-team/website Length of output: 431 Don't clear
💡 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 |
||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
|
Comment on lines
+23
to
+43
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use TanStack Query here 😛
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 😂😂😂
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>} | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make challenge failures announce themselves. This error node is inserted after the click, but a plain ♿ Proposed fix- {error && <span className="text-xs text-destructive">{error}</span>}
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| </div> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.