Merge pull request #93 from VashSoftware/queue_split
split queue into queue.ts + some tests
This commit is contained in:
@@ -13,7 +13,7 @@ import { db } from '$lib/server/db';
|
|||||||
import { user, playerRating, teamMember, team, matchInvite } from '$lib/server/db/schema';
|
import { user, playerRating, teamMember, team, matchInvite } from '$lib/server/db/schema';
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { setDMHandler, sendDM, getClient } from './client';
|
import { setDMHandler, sendDM, getClient } from './client';
|
||||||
import { joinQueue, leaveQueue, getQueueStatus } from '../match/engine';
|
import { joinQueue, leaveQueue, getQueueStatus } from '../match/queue';
|
||||||
import { acceptInvite, declineInvite, getInvitesForUser } from '../match/invites';
|
import { acceptInvite, declineInvite, getInvitesForUser } from '../match/invites';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import {
|
|||||||
matchParticipantPlayer,
|
matchParticipantPlayer,
|
||||||
matchGame,
|
matchGame,
|
||||||
matchGameScore,
|
matchGameScore,
|
||||||
matchQueue,
|
|
||||||
mappoolSlot,
|
mappoolSlot,
|
||||||
playerRating,
|
playerRating,
|
||||||
teamMember
|
teamMember
|
||||||
} from '$lib/server/db/schema';
|
} from '$lib/server/db/schema';
|
||||||
import { account } from '$lib/server/db/auth.schema';
|
import { account } from '$lib/server/db/auth.schema';
|
||||||
import { eq, and, asc, sql, inArray, lt } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
|
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
|
||||||
import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/client';
|
import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/client';
|
||||||
import { getUser } from '$lib/server/osu/api';
|
import { getUser } from '$lib/server/osu/api';
|
||||||
@@ -47,204 +46,6 @@ export async function calculateInitialElo(userId: string): Promise<{ elo: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Queue ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
||||||
|
|
||||||
async function purgeExpiredQueueEntries() {
|
|
||||||
const cutoff = new Date(Date.now() - QUEUE_TIMEOUT_MS);
|
|
||||||
const deleted = await db.delete(matchQueue).where(lt(matchQueue.joinedAt, cutoff)).returning();
|
|
||||||
|
|
||||||
if (deleted.length > 0) {
|
|
||||||
console.log(`[Queue] Purged ${deleted.length} expired queue entr${deleted.length === 1 ? 'y' : 'ies'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function joinQueue(userId: string, teamId: string) {
|
|
||||||
await purgeExpiredQueueEntries();
|
|
||||||
const activeMatchId = await findRecentActiveMatch(userId);
|
|
||||||
if (activeMatchId) throw new Error('You are already in an active match');
|
|
||||||
|
|
||||||
const rating = await db.query.playerRating.findFirst({
|
|
||||||
where: eq(playerRating.userId, userId)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!rating) throw new Error('No rating found — please re-register');
|
|
||||||
|
|
||||||
const existing = await db.query.matchQueue.findFirst({
|
|
||||||
where: eq(matchQueue.userId, userId)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) throw new Error('Already in queue');
|
|
||||||
|
|
||||||
await db.insert(matchQueue).values({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
elo: rating.elo
|
|
||||||
});
|
|
||||||
|
|
||||||
return tryMatchFromQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function leaveQueue(userId: string) {
|
|
||||||
await db.delete(matchQueue).where(eq(matchQueue.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQueueStatus(userId: string) {
|
|
||||||
await purgeExpiredQueueEntries();
|
|
||||||
const entry = await db.query.matchQueue.findFirst({
|
|
||||||
where: eq(matchQueue.userId, userId)
|
|
||||||
});
|
|
||||||
|
|
||||||
const count = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(matchQueue);
|
|
||||||
|
|
||||||
// If NOT in queue, check if there's a recent active match for this user
|
|
||||||
// (they may have been matched while polling)
|
|
||||||
let matchedMatchId: string | null = null;
|
|
||||||
if (!entry) {
|
|
||||||
matchedMatchId = await findRecentActiveMatch(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inQueue: !!entry,
|
|
||||||
queueSize: Number(count[0].count),
|
|
||||||
joinedAt: entry?.joinedAt ?? null,
|
|
||||||
matchedMatchId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a recent active (non-finished) match for this user.
|
|
||||||
* Used to redirect players who were matched via queue while polling.
|
|
||||||
*/
|
|
||||||
async function findRecentActiveMatch(userId: string): Promise<string | null> {
|
|
||||||
// Find matchParticipantPlayer records for this user
|
|
||||||
const playerEntries = await db.query.matchParticipantPlayer.findMany({
|
|
||||||
where: eq(matchParticipantPlayer.userId, userId),
|
|
||||||
with: {
|
|
||||||
participant: {
|
|
||||||
with: {
|
|
||||||
match: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the most recent match that's still active (not finished/cancelled)
|
|
||||||
const activeStates = [
|
|
||||||
MATCH_STATES.CREATED,
|
|
||||||
MATCH_STATES.LOBBY,
|
|
||||||
MATCH_STATES.ROLLING,
|
|
||||||
MATCH_STATES.PICKING,
|
|
||||||
MATCH_STATES.PLAYING
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeMatches = playerEntries
|
|
||||||
.filter((pe) => activeStates.includes(pe.participant.match.state as any))
|
|
||||||
.map((pe) => pe.participant.match)
|
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
|
|
||||||
return activeMatches[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_CONCURRENT_MATCHES = 4;
|
|
||||||
|
|
||||||
async function tryMatchFromQueue() {
|
|
||||||
// Hard cap: osu! non-bot accounts can only host a limited number of lobbies
|
|
||||||
const [{ activeCount }] = await db
|
|
||||||
.select({ activeCount: sql<number>`count(*)` })
|
|
||||||
.from(match)
|
|
||||||
.where(inArray(match.state, [MATCH_STATES.LOBBY, MATCH_STATES.ROLLING, MATCH_STATES.PICKING, MATCH_STATES.PLAYING]));
|
|
||||||
|
|
||||||
if (Number(activeCount) >= MAX_CONCURRENT_MATCHES) return null;
|
|
||||||
|
|
||||||
const queue = await db.query.matchQueue.findMany({
|
|
||||||
orderBy: asc(matchQueue.elo),
|
|
||||||
with: {
|
|
||||||
team: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (queue.length < 2) return null;
|
|
||||||
|
|
||||||
// Find closest ELO pair
|
|
||||||
let bestPair: [(typeof queue)[0], (typeof queue)[0]] | null = null;
|
|
||||||
let smallestGap = Infinity;
|
|
||||||
|
|
||||||
for (let i = 0; i < queue.length - 1; i++) {
|
|
||||||
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
|
|
||||||
if (gap < smallestGap) {
|
|
||||||
smallestGap = gap;
|
|
||||||
bestPair = [queue[i], queue[i + 1]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bestPair) return null;
|
|
||||||
|
|
||||||
// Pick mappool based on average player rating
|
|
||||||
const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2;
|
|
||||||
const selectedPool = await selectMappoolForRating(avgElo);
|
|
||||||
|
|
||||||
if (!selectedPool) throw new Error('No mappools available');
|
|
||||||
|
|
||||||
// Remove both from queue
|
|
||||||
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId));
|
|
||||||
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId));
|
|
||||||
|
|
||||||
const created = await createMatch({
|
|
||||||
name: `VASH: ${bestPair[0].team.name} vs ${bestPair[1].team.name}`,
|
|
||||||
config: { bestOf: 5, teamSize: 1, scoringType: 'score_v2' },
|
|
||||||
mappoolId: selectedPool.id,
|
|
||||||
teams: [bestPair[0].teamId, bestPair[1].teamId],
|
|
||||||
createdBy: 'system'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the IRC lobby in the background
|
|
||||||
// Import dynamically to avoid circular dependency
|
|
||||||
try {
|
|
||||||
const { initMatchLobby } = await import('./orchestrator');
|
|
||||||
initMatchLobby(created.id).catch((err) => {
|
|
||||||
console.error('[Queue] IRC lobby creation failed:', err.message);
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Queue] Failed to import orchestrator:', err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectMappoolForRating(avgElo: number) {
|
|
||||||
// Only use verified mappools
|
|
||||||
const pools = await db.query.mappool.findMany({
|
|
||||||
where: (m, { isNotNull }) => isNotNull(m.verifiedAt),
|
|
||||||
with: { slots: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pools.length === 0) return null;
|
|
||||||
|
|
||||||
// Map ELO range (0-3500) to star rating range (2-8)
|
|
||||||
const targetStars = 2 + avgElo / 700;
|
|
||||||
const clampedTarget = Math.max(2, Math.min(8, targetStars));
|
|
||||||
|
|
||||||
// Score each pool by distance from target
|
|
||||||
const scored = pools
|
|
||||||
.filter((p) => p.slots.length > 0)
|
|
||||||
.map((p) => {
|
|
||||||
const avgStars = p.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / p.slots.length;
|
|
||||||
return { pool: p, diff: Math.abs(avgStars - clampedTarget) };
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.diff - b.diff);
|
|
||||||
|
|
||||||
if (scored.length === 0) return null;
|
|
||||||
|
|
||||||
// Allow any pool within 1 star of the closest match, then pick randomly
|
|
||||||
const threshold = scored[0].diff + 1.0;
|
|
||||||
const candidates = scored.filter((s) => s.diff <= threshold);
|
|
||||||
return candidates[Math.floor(Math.random() * candidates.length)].pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create Match ────────────────────────────────────────────────────────
|
// ── Create Match ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createMatch(opts: {
|
export async function createMatch(opts: {
|
||||||
|
|||||||
201
src/lib/server/match/queue.ts
Normal file
201
src/lib/server/match/queue.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { match, matchQueue, matchParticipantPlayer, playerRating } from '$lib/server/db/schema';
|
||||||
|
import { eq, asc, sql, inArray, lt } from 'drizzle-orm';
|
||||||
|
import { MATCH_STATES } from './types';
|
||||||
|
import { createMatch } from './engine';
|
||||||
|
|
||||||
|
const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
async function purgeExpiredQueueEntries() {
|
||||||
|
const cutoff = new Date(Date.now() - QUEUE_TIMEOUT_MS);
|
||||||
|
const deleted = await db.delete(matchQueue).where(lt(matchQueue.joinedAt, cutoff)).returning();
|
||||||
|
|
||||||
|
if (deleted.length > 0) {
|
||||||
|
console.log(`[Queue] Purged ${deleted.length} expired queue entr${deleted.length === 1 ? 'y' : 'ies'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinQueue(userId: string, teamId: string) {
|
||||||
|
await purgeExpiredQueueEntries();
|
||||||
|
const activeMatchId = await findRecentActiveMatch(userId);
|
||||||
|
if (activeMatchId) throw new Error('You are already in an active match');
|
||||||
|
|
||||||
|
const rating = await db.query.playerRating.findFirst({
|
||||||
|
where: eq(playerRating.userId, userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rating) throw new Error('No rating found — please re-register');
|
||||||
|
|
||||||
|
const existing = await db.query.matchQueue.findFirst({
|
||||||
|
where: eq(matchQueue.userId, userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) throw new Error('Already in queue');
|
||||||
|
|
||||||
|
await db.insert(matchQueue).values({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
elo: rating.elo
|
||||||
|
});
|
||||||
|
|
||||||
|
return tryMatchFromQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveQueue(userId: string) {
|
||||||
|
await db.delete(matchQueue).where(eq(matchQueue.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueueStatus(userId: string) {
|
||||||
|
await purgeExpiredQueueEntries();
|
||||||
|
const entry = await db.query.matchQueue.findFirst({
|
||||||
|
where: eq(matchQueue.userId, userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(matchQueue);
|
||||||
|
|
||||||
|
// If NOT in queue, check if there's a recent active match for this user
|
||||||
|
// (they may have been matched while polling)
|
||||||
|
let matchedMatchId: string | null = null;
|
||||||
|
if (!entry) {
|
||||||
|
matchedMatchId = await findRecentActiveMatch(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inQueue: !!entry,
|
||||||
|
queueSize: Number(count[0].count),
|
||||||
|
joinedAt: entry?.joinedAt ?? null,
|
||||||
|
matchedMatchId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a recent active (non-finished) match for this user.
|
||||||
|
* Used to redirect players who were matched via queue while polling.
|
||||||
|
*/
|
||||||
|
async function findRecentActiveMatch(userId: string): Promise<string | null> {
|
||||||
|
// Find matchParticipantPlayer records for this user
|
||||||
|
const playerEntries = await db.query.matchParticipantPlayer.findMany({
|
||||||
|
where: eq(matchParticipantPlayer.userId, userId),
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
with: {
|
||||||
|
match: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the most recent match that's still active (not finished/cancelled)
|
||||||
|
const activeStates = [
|
||||||
|
MATCH_STATES.CREATED,
|
||||||
|
MATCH_STATES.LOBBY,
|
||||||
|
MATCH_STATES.ROLLING,
|
||||||
|
MATCH_STATES.PICKING,
|
||||||
|
MATCH_STATES.PLAYING
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeMatches = playerEntries
|
||||||
|
.filter((pe) => activeStates.includes(pe.participant.match.state as any))
|
||||||
|
.map((pe) => pe.participant.match)
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return activeMatches[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_MATCHES = 4;
|
||||||
|
|
||||||
|
async function tryMatchFromQueue() {
|
||||||
|
// Hard cap: osu! non-bot accounts can only host a limited number of lobbies
|
||||||
|
const [{ activeCount }] = await db
|
||||||
|
.select({ activeCount: sql<number>`count(*)` })
|
||||||
|
.from(match)
|
||||||
|
.where(inArray(match.state, [MATCH_STATES.LOBBY, MATCH_STATES.ROLLING, MATCH_STATES.PICKING, MATCH_STATES.PLAYING]));
|
||||||
|
|
||||||
|
if (Number(activeCount) >= MAX_CONCURRENT_MATCHES) return null;
|
||||||
|
|
||||||
|
const queue = await db.query.matchQueue.findMany({
|
||||||
|
orderBy: asc(matchQueue.elo),
|
||||||
|
with: {
|
||||||
|
team: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queue.length < 2) return null;
|
||||||
|
|
||||||
|
// Find closest ELO pair
|
||||||
|
let bestPair: [(typeof queue)[0], (typeof queue)[0]] | null = null;
|
||||||
|
let smallestGap = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < queue.length - 1; i++) {
|
||||||
|
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
|
||||||
|
if (gap < smallestGap) {
|
||||||
|
smallestGap = gap;
|
||||||
|
bestPair = [queue[i], queue[i + 1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestPair) return null;
|
||||||
|
|
||||||
|
// Pick mappool based on average player rating
|
||||||
|
const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2;
|
||||||
|
const selectedPool = await selectMappoolForRating(avgElo);
|
||||||
|
|
||||||
|
if (!selectedPool) throw new Error('No mappools available');
|
||||||
|
|
||||||
|
// Remove both from queue
|
||||||
|
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId));
|
||||||
|
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId));
|
||||||
|
|
||||||
|
const created = await createMatch({
|
||||||
|
name: `VASH: ${bestPair[0].team.name} vs ${bestPair[1].team.name}`,
|
||||||
|
config: { bestOf: 5, teamSize: 1, scoringType: 'score_v2' },
|
||||||
|
mappoolId: selectedPool.id,
|
||||||
|
teams: [bestPair[0].teamId, bestPair[1].teamId],
|
||||||
|
createdBy: 'system'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the IRC lobby in the background
|
||||||
|
// Import dynamically to avoid circular dependency
|
||||||
|
try {
|
||||||
|
const { initMatchLobby } = await import('./orchestrator');
|
||||||
|
initMatchLobby(created.id).catch((err) => {
|
||||||
|
console.error('[Queue] IRC lobby creation failed:', err.message);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Queue] Failed to import orchestrator:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMappoolForRating(avgElo: number) {
|
||||||
|
// Only use verified mappools
|
||||||
|
const pools = await db.query.mappool.findMany({
|
||||||
|
where: (m, { isNotNull }) => isNotNull(m.verifiedAt),
|
||||||
|
with: { slots: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pools.length === 0) return null;
|
||||||
|
|
||||||
|
// Map ELO range (0-3500) to star rating range (2-8)
|
||||||
|
const targetStars = 2 + avgElo / 700;
|
||||||
|
const clampedTarget = Math.max(2, Math.min(8, targetStars));
|
||||||
|
|
||||||
|
// Score each pool by distance from target
|
||||||
|
const scored = pools
|
||||||
|
.filter((p) => p.slots.length > 0)
|
||||||
|
.map((p) => {
|
||||||
|
const avgStars = p.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / p.slots.length;
|
||||||
|
return { pool: p, diff: Math.abs(avgStars - clampedTarget) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.diff - b.diff);
|
||||||
|
|
||||||
|
if (scored.length === 0) return null;
|
||||||
|
|
||||||
|
// Allow any pool within 1 star of the closest match, then pick randomly
|
||||||
|
const threshold = scored[0].diff + 1.0;
|
||||||
|
const candidates = scored.filter((s) => s.diff <= threshold);
|
||||||
|
return candidates[Math.floor(Math.random() * candidates.length)].pool;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match, team, mappool } from '$lib/server/db/schema';
|
import { match, team, mappool } from '$lib/server/db/schema';
|
||||||
import { eq, desc, count, inArray } from 'drizzle-orm';
|
import { eq, desc, count, inArray } from 'drizzle-orm';
|
||||||
import { getQueueStatus } from '$lib/server/match/engine';
|
import { getQueueStatus } from '$lib/server/match/queue';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/routes/api/queue/+server.ts
|
// src/routes/api/queue/+server.ts
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { joinQueue, leaveQueue, getQueueStatus } from '$lib/server/match/engine';
|
import { joinQueue, leaveQueue, getQueueStatus } from '$lib/server/match/queue';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { teamMember } from '$lib/server/db/schema';
|
import { teamMember } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|||||||
87
test/match/queue.test.ts
Normal file
87
test/match/queue.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { beforeEach, describe, expect, test, mock } from 'bun:test';
|
||||||
|
import { MATCH_STATES } from '../../src/lib/server/match/types';
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
matchParticipantPlayer: { findMany: mock() },
|
||||||
|
playerRating: { findFirst: mock() },
|
||||||
|
matchQueue: { findFirst: mock() }
|
||||||
|
},
|
||||||
|
delete: mock()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module('$lib/server/db', () => ({ db: mocks.db }));
|
||||||
|
|
||||||
|
mock.module('drizzle-orm', () => ({
|
||||||
|
eq: mock(() => ({})),
|
||||||
|
asc: mock(() => ({})),
|
||||||
|
lt: mock(() => ({})),
|
||||||
|
inArray: mock(() => ({})),
|
||||||
|
sql: mock(() => ({}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('$lib/server/db/schema', () => ({
|
||||||
|
match: {},
|
||||||
|
matchQueue: {},
|
||||||
|
matchParticipantPlayer: {},
|
||||||
|
playerRating: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('$lib/server/match/engine', () => ({
|
||||||
|
createMatch: mock()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { joinQueue } = await import('../../src/lib/server/match/queue');
|
||||||
|
|
||||||
|
describe('joinQueue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.db.query.matchParticipantPlayer.findMany.mockReset();
|
||||||
|
mocks.db.query.playerRating.findFirst.mockReset();
|
||||||
|
mocks.db.query.matchQueue.findFirst.mockReset();
|
||||||
|
mocks.db.delete.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when user is already in an active match', async () => {
|
||||||
|
mocks.db.delete.mockReturnValue({
|
||||||
|
where: mock(() => ({ returning: mock(() => Promise.resolve([])) }))
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.db.query.matchParticipantPlayer.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
participant: {
|
||||||
|
match: { id: 'match-1', state: MATCH_STATES.PLAYING, createdAt: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow('You are already in an active match');
|
||||||
|
expect(mocks.db.query.playerRating.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when user has no rating', async () => {
|
||||||
|
mocks.db.delete.mockReturnValue({
|
||||||
|
where: mock(() => ({ returning: mock(() => Promise.resolve([])) }))
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.db.query.matchParticipantPlayer.findMany.mockResolvedValue([]);
|
||||||
|
mocks.db.query.playerRating.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow(
|
||||||
|
'No rating found — please re-register'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when user is already in queue', async() => {
|
||||||
|
mocks.db.delete.mockReturnValue({
|
||||||
|
where: mock(() => ({ returning: mock(() => Promise.resolve([])) }))
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.db.query.matchParticipantPlayer.findMany.mockResolvedValue([]);
|
||||||
|
mocks.db.query.playerRating.findFirst.mockResolvedValue({ elo: 1500 });
|
||||||
|
mocks.db.query.matchQueue.findFirst.mockResolvedValue({ userId: 'user-1', teamId: 'team-1' });
|
||||||
|
|
||||||
|
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow('Already in queue');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user