Skip to content
Merged
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
27 changes: 17 additions & 10 deletions api/src/services/matchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ class MatchService {
async createMatch(
input: CreateMatchInput,
baseUrl: string,
defaultsTournamentId: number
/** The tournament whose rules fill gaps (round limit); null: none (matchmaking). */
defaultsTournamentId: number | null
): Promise<MatchResponse> {
// Check if slug already exists
const existing = await db.getOneAsync<Match>('matches', 'slug = ?', [input.slug]);
Expand All @@ -39,14 +40,17 @@ class MatchService {

// Manual matches borrow the primary tournament's rules (e.g. the round
// limit) for anything the admin left out.
const tournament = await db
.queryOneAsync<DbTournamentRow>('SELECT * FROM tournament WHERE id = ?', [
defaultsTournamentId,
])
.catch((error: unknown) => {
log.warn('Failed to read round-limit defaults for manual match', error as Error);
return null;
});
const tournament =
defaultsTournamentId === null
? null
: await db
.queryOneAsync<DbTournamentRow>('SELECT * FROM tournament WHERE id = ?', [
defaultsTournamentId,
])
.catch((error: unknown) => {
log.warn('Failed to read round-limit defaults for manual match', error as Error);
return null;
});

// The integration builds the stored config from the admin's settings
// (for CS2: simulation, round limit, default MatchZy Enhanced cvars and
Expand Down Expand Up @@ -159,7 +163,10 @@ class MatchService {
/**
* Update match status
*/
async updateMatchStatus(slug: string, status: 'pending' | 'loaded' | 'live' | 'completed'): Promise<void> {
async updateMatchStatus(
slug: string,
status: 'pending' | 'loaded' | 'live' | 'completed'
): Promise<void> {
const match = await db.getOneAsync<Match>('matches', 'slug = ?', [slug]);
if (!match) {
throw new Error(`Match '${slug}' not found`);
Expand Down
572 changes: 428 additions & 144 deletions api/src/services/matchmaking/matchmakingService.ts

Large diffs are not rendered by default.

56 changes: 46 additions & 10 deletions api/src/services/matchmaking/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export function parseEnabledModes(raw: unknown): MatchmakingMode[] {
} catch {
v = null;
}
const modes = Array.isArray(v) ? [...new Set(v.filter((m): m is MatchmakingMode => isMode(m)))] : [];
const modes = Array.isArray(v)
? [...new Set(v.filter((m): m is MatchmakingMode => isMode(m)))]
: [];
return modes.length > 0 ? modes : DEFAULT_MODES;
}

Expand Down Expand Up @@ -163,7 +165,10 @@ export function parseSearchWindow(raw: unknown): SearchWindowSettings {
const start = num(v.start, 0, 2000, d.start);
const step = num(v.step, 0, 1000, d.step);
const cap = Math.max(start, num(v.cap, 0, 5000, d.cap));
const minutes = v.uncappedAfterMinutes === null ? null : num(v.uncappedAfterMinutes, 1, 120, d.uncappedAfterSeconds / 60);
const minutes =
v.uncappedAfterMinutes === null
? null
: num(v.uncappedAfterMinutes, 1, 120, d.uncappedAfterSeconds / 60);
return {
start,
step,
Expand All @@ -178,7 +183,8 @@ export function validateSearchWindow(
input: unknown
): { start: number; step: number; cap: number; uncappedAfterMinutes: number | null } | null {
const v = (input ?? {}) as Record<string, unknown>;
const int = (x: unknown, min: number, max: number) => typeof x === 'number' && Number.isInteger(x) && x >= min && x <= max;
const int = (x: unknown, min: number, max: number) =>
typeof x === 'number' && Number.isInteger(x) && x >= min && x <= max;
if (!int(v.start, 0, 2000) || !int(v.step, 0, 1000) || !int(v.cap, 0, 5000)) return null;
if ((v.cap as number) < (v.start as number)) return null;
if (v.uncappedAfterMinutes !== null && !int(v.uncappedAfterMinutes, 1, 120)) return null;
Expand All @@ -191,7 +197,10 @@ export function validateSearchWindow(
}

/** The search window in mu after `waitedSeconds` in the queue (Infinity = any rating). */
export function searchWindow(waitedSeconds: number, s: SearchWindowSettings = DEFAULT_SEARCH_WINDOW): number {
export function searchWindow(
waitedSeconds: number,
s: SearchWindowSettings = DEFAULT_SEARCH_WINDOW
): number {
const waited = Math.max(0, waitedSeconds);
if (waited >= s.uncappedAfterSeconds) return Number.POSITIVE_INFINITY;
const elo = Math.min(s.cap, s.start + Math.floor(waited / s.everySeconds) * s.step);
Expand All @@ -217,7 +226,9 @@ export function partyRating(mus: number[]): number {
function compatible(a: QueuedParty, b: QueuedParty): boolean {
if (a.rating === undefined || b.rating === undefined) return true;
const gap = Math.abs(a.rating - b.rating);
return gap <= (a.window ?? Number.POSITIVE_INFINITY) && gap <= (b.window ?? Number.POSITIVE_INFINITY);
return (
gap <= (a.window ?? Number.POSITIVE_INFINITY) && gap <= (b.window ?? Number.POSITIVE_INFINITY)
);
}

/**
Expand All @@ -226,14 +237,29 @@ function compatible(a: QueuedParty, b: QueuedParty): boolean {
* the anchor, then others are added oldest first; if no exact fill exists for
* that anchor, the next oldest is tried. Returns null when nothing fits.
*/
/**
* Few searching: fewer players in the mode's queue than two matches need. Then
* ratings do not hold anyone back, a match now beats a close one later (two
* players far apart waited five minutes in an empty 1v1 queue, 2026-10-12).
* With more searching, the rating window applies as before.
*/
export function fewSearching(queue: QueuedParty[], teamSize: number): boolean {
const players = queue.reduce((n, p) => n + p.players.length, 0);
return players < teamSize * 2 * 2;
}

export function findGroup(queue: QueuedParty[], teamSize: number): QueuedParty[] | null {
const need = teamSize * 2;
const open = fewSearching(queue, teamSize);
const ordered = [...queue]
.map((p) => (open ? { ...p, window: Number.POSITIVE_INFINITY } : p))
.filter((p) => p.players.length > 0 && p.players.length <= teamSize)
.sort((a, b) => a.queuedAt - b.queuedAt || a.entryId.localeCompare(b.entryId));

for (let anchor = 0; anchor < ordered.length; anchor++) {
const picked = pickExact(ordered.slice(anchor + 1), need - ordered[anchor].players.length, [ordered[anchor]]);
const picked = pickExact(ordered.slice(anchor + 1), need - ordered[anchor].players.length, [
ordered[anchor],
]);
if (picked) {
const group = [ordered[anchor], ...picked];
if (splitTeams(group, teamSize)) return group;
Expand All @@ -246,7 +272,11 @@ export function findGroup(queue: QueuedParty[], teamSize: number): QueuedParty[]
* Oldest-first subset of `candidates` with exactly `need` players, each
* compatible with every party already `chosen` (backtracking; queues are small).
*/
function pickExact(candidates: QueuedParty[], need: number, chosen: QueuedParty[]): QueuedParty[] | null {
function pickExact(
candidates: QueuedParty[],
need: number,
chosen: QueuedParty[]
): QueuedParty[] | null {
if (need === 0) return [];
for (let i = 0; i < candidates.length; i++) {
const size = candidates[i].players.length;
Expand Down Expand Up @@ -287,7 +317,8 @@ export function splitTeams(
let candidates = splits;
if (group.every((p) => p.mus && p.mus.length === p.players.length)) {
const score = (mask: number): [number, number] => {
const side = (inTeam1: boolean) => group.filter((_, i) => !!(mask & (1 << i)) === inTeam1).flatMap((p) => p.mus!);
const side = (inTeam1: boolean) =>
group.filter((_, i) => !!(mask & (1 << i)) === inTeam1).flatMap((p) => p.mus!);
const a = side(true);
const b = side(false);
const sum = (xs: number[]) => xs.reduce((x, y) => x + y, 0);
Expand All @@ -301,7 +332,8 @@ export function splitTeams(
candidates = byTotal.filter((x) => x.s[1] <= bestTop + EPS).map((x) => x.mask);
}

const mask = candidates[Math.min(candidates.length - 1, Math.floor(random() * candidates.length))];
const mask =
candidates[Math.min(candidates.length - 1, Math.floor(random() * candidates.length))];
const team1 = group.filter((_, i) => mask & (1 << i));
const team2 = group.filter((_, i) => !(mask & (1 << i)));
return random() < 0.5 ? [team1, team2] : [team2, team1];
Expand All @@ -325,7 +357,11 @@ export function inviteCode(): string {
* (decided 2026-09-29). Only while matchmaking has players waiting, so
* nothing is held back for nobody.
*/
export function freeForTournament(free: number, reserved: number, matchmakingWaiting: boolean): number {
export function freeForTournament(
free: number,
reserved: number,
matchmakingWaiting: boolean
): number {
if (!matchmakingWaiting || reserved <= 0) return free;
return Math.max(0, free - reserved);
}
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/de/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
6 changes: 4 additions & 2 deletions client/src/locales/en/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@
"1v1": "Duel"
},
"lookingTitle": "Looking for players",
"seats": "{{count}} of {{total}} in the queue",
"seats": "{{count}} searching · {{total}} needed for a match",
"leaveQueue": "Leave the queue",
"level": "Your level",
"xp": "{{into}} / {{next}} XP",
Expand Down Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/es/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/fr/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/it/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
6 changes: 4 additions & 2 deletions client/src/locales/nb/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@
"1v1": "Duell"
},
"lookingTitle": "Leter etter spillere",
"seats": "{{count}} av {{total}} i køen",
"seats": "{{count}} søker · {{total}} trengs for en kamp",
"leaveQueue": "Forlat køen",
"level": "Ditt nivå",
"xp": "{{into}} / {{next}} XP",
Expand Down Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman-kart",
"1v1": "Active Duty"
}
},
"searchAnyone": "Få spillere søker: du blir satt opp mot hvem som helst ({{count}} søker, {{total}} trengs)",
"searchRating": "Finner en jevn kamp for ratingen din · {{count}} søker"
},
"queue": {
"searching": "Søker {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/nl/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/pl/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/pt-PT/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/zh-CN/translation/misc.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,9 @@
"5v5": "Active Duty",
"2v2": "Wingman maps",
"1v1": "Active Duty"
}
},
"searchAnyone": "Few players searching: you'll be matched with anyone available ({{count}} searching, {{total}} needed)",
"searchRating": "Finding a close match for your rating · {{count}} searching"
},
"queue": {
"searching": "Searching {{mode}}",
Expand Down
Loading
Loading