diff --git a/api/src/services/matchService.ts b/api/src/services/matchService.ts index b17d810a2..c2bd21988 100644 --- a/api/src/services/matchService.ts +++ b/api/src/services/matchService.ts @@ -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 { // Check if slug already exists const existing = await db.getOneAsync('matches', 'slug = ?', [input.slug]); @@ -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('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('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 @@ -159,7 +163,10 @@ class MatchService { /** * Update match status */ - async updateMatchStatus(slug: string, status: 'pending' | 'loaded' | 'live' | 'completed'): Promise { + async updateMatchStatus( + slug: string, + status: 'pending' | 'loaded' | 'live' | 'completed' + ): Promise { const match = await db.getOneAsync('matches', 'slug = ?', [slug]); if (!match) { throw new Error(`Match '${slug}' not found`); diff --git a/api/src/services/matchmaking/matchmakingService.ts b/api/src/services/matchmaking/matchmakingService.ts index ad3876626..86826fa43 100644 --- a/api/src/services/matchmaking/matchmakingService.ts +++ b/api/src/services/matchmaking/matchmakingService.ts @@ -16,7 +16,6 @@ import { db } from '../../config/database'; import { log } from '../../utils/logger'; import { hasIntegration, getIntegration } from '../../integrations/registry'; import { matchService } from '../matchService'; -import { resolveTournamentId } from '../../utils/tournamentRow'; import { configuredPublicOrigin } from '../../utils/publicOrigin'; import { rating as osRating, rate as osRate } from 'openskill'; import { DEFAULT_SIGMA, openSkillToDisplayElo } from '../../utils/ratingMath'; @@ -109,7 +108,12 @@ export interface MatchmakingMe { /** Party members (or the caller alone) not set up for the game yet: the game on their profile, or its account. Searching waits for them. */ notReady: Array<{ id: string; name: string; missing: 'game' | 'account' }>; /** Open invites to the caller from other parties, newest first. */ - invites: Array<{ partyId: string; from: { id: string; name: string; avatarUrl: string | null }; mode: string; size: number }>; + invites: Array<{ + partyId: string; + from: { id: string; name: string; avatarUrl: string | null }; + mode: string; + size: number; + }>; queue: { mode: string; queuedAt: number; status: string } | null; /** Players searching right now, per mode (the Play page's "in the queue"). */ queueCounts: Record; @@ -140,6 +144,20 @@ export interface MatchmakingMe { /** Rounds per half Ready Up plays: wingman MR8, everything else MR12. */ const MODE_MAX_ROUNDS: Record = { '5v5': 12, '2v2': 8, '1v1': 12 }; + +/** + * Sides and objective per mode. 5v5 keeps the knife round. 1v1 and 2v2 draw + * the starting sides (a knife duel for sides is no fun with one or two + * players); 1v1 is an aim duel with no bomb, elimination only. + */ +export function matchmakingSides(mode: MatchmakingMode): { + map_sides?: string[]; + cvars?: Record; +} { + if (mode === '5v5') return {}; + const sides = { map_sides: [randomInt(2) === 0 ? 'team1_ct' : 'team2_ct'] }; + return mode === '1v1' ? { ...sides, cvars: { mp_give_player_c4: 0 } } : sides; +} /** Fewer found matches than this in the last week says nothing about the wait. */ const MIN_WAIT_SAMPLES = 3; @@ -213,7 +231,10 @@ export class MatchmakingService { /** The game a party plays (matchmaking is CS2 today; the column is ready for more). */ private async partyGame(partyId: string): Promise { - const row = await db.queryOneAsync<{ game: string | null }>('SELECT game FROM mm_parties WHERE id = ?', [partyId]); + const row = await db.queryOneAsync<{ game: string | null }>( + 'SELECT game FROM mm_parties WHERE id = ?', + [partyId] + ); return row?.game || 'cs2'; } @@ -231,20 +252,26 @@ export class MatchmakingService { } private async entryOf(partyId: string): Promise { - return db.queryOneAsync('SELECT * FROM mm_queue_entries WHERE party_id = ?', [partyId]); + return db.queryOneAsync('SELECT * FROM mm_queue_entries WHERE party_id = ?', [ + partyId, + ]); } private async newParty(playerId: string, mode: MatchmakingMode): Promise { - const party: PartyRow = { id: randomUUID(), leader_player_id: playerId, mode, invite_code: inviteCode() }; + const party: PartyRow = { + id: randomUUID(), + leader_player_id: playerId, + mode, + invite_code: inviteCode(), + }; await db.runAsync( 'INSERT INTO mm_parties (id, leader_player_id, mode, invite_code, created_at) VALUES (?, ?, ?, ?, ?)', [party.id, playerId, mode, party.invite_code, this.clock()] ); - await db.runAsync('INSERT INTO mm_party_members (party_id, player_id, joined_at) VALUES (?, ?, ?)', [ - party.id, - playerId, - this.clock(), - ]); + await db.runAsync( + 'INSERT INTO mm_party_members (party_id, player_id, joined_at) VALUES (?, ?, ?)', + [party.id, playerId, this.clock()] + ); return party; } @@ -265,45 +292,55 @@ export class MatchmakingService { 'SELECT id, leader_player_id, mode, invite_code FROM mm_parties WHERE invite_code = ?', [code.trim().toUpperCase()] ); - if (!party) throw new MatchmakingError(404, 'party_not_found', 'No party with that invite code'); + if (!party) + throw new MatchmakingError(404, 'party_not_found', 'No party with that invite code'); return this.enter(playerId, party); }); } /** Put the player in this party (leaving theirs): the shared half of joining by code and by invite. */ private async enter(playerId: string, party: PartyRow): Promise { - const current = await this.partyOf(playerId); - if (current?.id === party.id) return party; - if (await this.entryOf(party.id)) { - throw new MatchmakingError(409, 'party_searching', 'That party is searching; ask the leader to stop first'); - } - // A party can grow to the biggest mode on the site; when it outgrows its - // mode, it moves to the smallest one it fits. - const modes = await this.enabledModes(); - const members = await this.members(party.id); - if (members.length >= Math.max(...modes.map((m) => TEAM_SIZE[m]))) { - throw new MatchmakingError(409, 'party_full', 'That party is full'); - } - if (current) await this.leave(playerId, current); - await db.runAsync('INSERT INTO mm_party_members (party_id, player_id, joined_at) VALUES (?, ?, ?)', [ - party.id, - playerId, - this.clock(), - ]); - const size = members.length + 1; - if (size > TEAM_SIZE[party.mode as MatchmakingMode]) { - const fits = modes.filter((m) => TEAM_SIZE[m] >= size).sort((a, b) => TEAM_SIZE[a] - TEAM_SIZE[b]); - if (fits[0]) { - party.mode = fits[0]; - await db.runAsync('UPDATE mm_parties SET mode = ? WHERE id = ?', [party.mode, party.id]); - } + const current = await this.partyOf(playerId); + if (current?.id === party.id) return party; + if (await this.entryOf(party.id)) { + throw new MatchmakingError( + 409, + 'party_searching', + 'That party is searching; ask the leader to stop first' + ); + } + // A party can grow to the biggest mode on the site; when it outgrows its + // mode, it moves to the smallest one it fits. + const modes = await this.enabledModes(); + const members = await this.members(party.id); + if (members.length >= Math.max(...modes.map((m) => TEAM_SIZE[m]))) { + throw new MatchmakingError(409, 'party_full', 'That party is full'); + } + if (current) await this.leave(playerId, current); + await db.runAsync( + 'INSERT INTO mm_party_members (party_id, player_id, joined_at) VALUES (?, ?, ?)', + [party.id, playerId, this.clock()] + ); + const size = members.length + 1; + if (size > TEAM_SIZE[party.mode as MatchmakingMode]) { + const fits = modes + .filter((m) => TEAM_SIZE[m] >= size) + .sort((a, b) => TEAM_SIZE[a] - TEAM_SIZE[b]); + if (fits[0]) { + party.mode = fits[0]; + await db.runAsync('UPDATE mm_parties SET mode = ? WHERE id = ?', [party.mode, party.id]); } - // An invite to this party is answered by being in it. - const invited = await db.queryAsync('DELETE FROM mm_party_invites WHERE party_id = ? AND to_id = ? RETURNING to_id', [party.id, playerId]); - if (invited.length > 0) await notificationService.answer(playerId, inviteKey(party.id), 'joined'); - await this.touchParty(party.id); - emitSocialChanged([playerId]); - return party; + } + // An invite to this party is answered by being in it. + const invited = await db.queryAsync( + 'DELETE FROM mm_party_invites WHERE party_id = ? AND to_id = ? RETURNING to_id', + [party.id, playerId] + ); + if (invited.length > 0) + await notificationService.answer(playerId, inviteKey(party.id), 'joined'); + await this.touchParty(party.id); + emitSocialChanged([playerId]); + return party; } /** @@ -316,32 +353,56 @@ export class MatchmakingService { if (typeof targetId !== 'string' || !/^\d{1,20}$/.test(targetId)) { throw new MatchmakingError(400, 'invalid_player', 'That is not a player'); } - if (targetId === playerId) throw new MatchmakingError(400, 'self', 'You are already in your party'); - const target = await db.queryOneAsync<{ id: string; name: string }>('SELECT id, name FROM players WHERE id = ?', [targetId]); + if (targetId === playerId) + throw new MatchmakingError(400, 'self', 'You are already in your party'); + const target = await db.queryOneAsync<{ id: string; name: string }>( + 'SELECT id, name FROM players WHERE id = ?', + [targetId] + ); if (!target) throw new MatchmakingError(404, 'not_found', 'No player with that id'); const modes = await this.enabledModes(); const current = await this.partyOf(playerId); const game = current ? await this.partyGame(current.id) : 'cs2'; const allowed = await canInvite(playerId, targetId, game); - if (allowed === 'nobody') throw new MatchmakingError(403, 'invites_off', `${target.name} does not take party invites`); - if (allowed === 'friends') throw new MatchmakingError(403, 'friends_only', `${target.name} takes party invites from friends only`); + if (allowed === 'nobody') + throw new MatchmakingError( + 403, + 'invites_off', + `${target.name} does not take party invites` + ); + if (allowed === 'friends') + throw new MatchmakingError( + 403, + 'friends_only', + `${target.name} takes party invites from friends only` + ); if (allowed === 'no_game') { - throw new MatchmakingError(409, 'no_game', `${target.name} has not added ${gameName(game)} to their profile`); + throw new MatchmakingError( + 409, + 'no_game', + `${target.name} has not added ${gameName(game)} to their profile` + ); } const party = current ?? (await this.newParty(playerId, modes[0] ?? '5v5')); const members = await this.members(party.id); - if (members.includes(targetId)) throw new MatchmakingError(409, 'already_member', 'They are already in your party'); - const open = await db.queryAsync<{ to_id: string }>('SELECT to_id FROM mm_party_invites WHERE party_id = ?', [party.id]); + if (members.includes(targetId)) + throw new MatchmakingError(409, 'already_member', 'They are already in your party'); + const open = await db.queryAsync<{ to_id: string }>( + 'SELECT to_id FROM mm_party_invites WHERE party_id = ?', + [party.id] + ); if (open.some((r) => r.to_id === targetId)) return; if (members.length + open.length >= Math.max(...modes.map((m) => TEAM_SIZE[m]))) { - throw new MatchmakingError(409, 'party_full', 'Your party is full, counting the invites still open'); + throw new MatchmakingError( + 409, + 'party_full', + 'Your party is full, counting the invites still open' + ); } - await db.runAsync('INSERT INTO mm_party_invites (party_id, to_id, from_id, created_at) VALUES (?, ?, ?, ?)', [ - party.id, - targetId, - playerId, - this.clock(), - ]); + await db.runAsync( + 'INSERT INTO mm_party_invites (party_id, to_id, from_id, created_at) VALUES (?, ?, ?, ?)', + [party.id, targetId, playerId, this.clock()] + ); const [from] = await this.people([playerId]); await notificationService.notify( targetId, @@ -360,7 +421,10 @@ export class MatchmakingService { return this.locked(async () => { const party = await this.partyOf(playerId); if (!party || typeof targetId !== 'string') return; - await db.runAsync('DELETE FROM mm_party_invites WHERE party_id = ? AND to_id = ?', [party.id, targetId]); + await db.runAsync('DELETE FROM mm_party_invites WHERE party_id = ? AND to_id = ?', [ + party.id, + targetId, + ]); await notificationService.withdraw(targetId, inviteKey(party.id)); await this.touchParty(party.id); this.touch(targetId); @@ -371,18 +435,33 @@ export class MatchmakingService { /** Answer an invite: join that party (leaving your own), or decline it. */ answerInvite(playerId: string, partyId: unknown, accept: boolean): Promise { return this.locked(async () => { - if (typeof partyId !== 'string') throw new MatchmakingError(400, 'invalid_party', 'That is not a party'); - const invite = await db.queryOneAsync('SELECT 1 FROM mm_party_invites WHERE party_id = ? AND to_id = ?', [partyId, playerId]); - if (!invite) throw new MatchmakingError(404, 'invite_gone', 'That invite is gone: the party ended or took it back'); + if (typeof partyId !== 'string') + throw new MatchmakingError(400, 'invalid_party', 'That is not a party'); + const invite = await db.queryOneAsync( + 'SELECT 1 FROM mm_party_invites WHERE party_id = ? AND to_id = ?', + [partyId, playerId] + ); + if (!invite) + throw new MatchmakingError( + 404, + 'invite_gone', + 'That invite is gone: the party ended or took it back' + ); if (!accept) { - await db.runAsync('DELETE FROM mm_party_invites WHERE party_id = ? AND to_id = ?', [partyId, playerId]); + await db.runAsync('DELETE FROM mm_party_invites WHERE party_id = ? AND to_id = ?', [ + partyId, + playerId, + ]); await notificationService.answer(playerId, inviteKey(partyId), 'declined'); await this.touchParty(partyId); this.touch(playerId); emitSocialChanged([playerId]); return; } - const party = await db.queryOneAsync('SELECT id, leader_player_id, mode, invite_code FROM mm_parties WHERE id = ?', [partyId]); + const party = await db.queryOneAsync( + 'SELECT id, leader_player_id, mode, invite_code FROM mm_parties WHERE id = ?', + [partyId] + ); if (!party) throw new MatchmakingError(404, 'invite_gone', 'That party has ended'); await this.enter(playerId, party); }); @@ -396,15 +475,32 @@ export class MatchmakingService { } /** Open invites to the player, newest first (the Play page shows them). */ - private async invitesTo(playerId: string): Promise> { - const rows = await db.queryAsync<{ party_id: string; from_id: string; mode: string; size: number | string }>( + private async invitesTo(playerId: string): Promise< + Array<{ + partyId: string; + from: { id: string; name: string; avatarUrl: string | null }; + mode: string; + size: number; + }> + > { + const rows = await db.queryAsync<{ + party_id: string; + from_id: string; + mode: string; + size: number | string; + }>( `SELECT i.party_id, i.from_id, p.mode, (SELECT COUNT(*) FROM mm_party_members m WHERE m.party_id = p.id) AS size FROM mm_party_invites i JOIN mm_parties p ON p.id = i.party_id WHERE i.to_id = ? ORDER BY i.created_at DESC`, [playerId] ); const people = await this.people(rows.map((r) => r.from_id)); - return rows.map((r, i) => ({ partyId: r.party_id, from: people[i], mode: r.mode, size: Number(r.size) })); + return rows.map((r, i) => ({ + partyId: r.party_id, + from: people[i], + mode: r.mode, + size: Number(r.size), + })); } /** The leader picks the party's mode. Not while searching, and only a mode the party fits. */ @@ -420,10 +516,18 @@ export class MatchmakingService { } if (party.mode === mode) return party; if (await this.entryOf(party.id)) { - throw new MatchmakingError(409, 'party_searching', 'Stop the search before changing the mode'); + throw new MatchmakingError( + 409, + 'party_searching', + 'Stop the search before changing the mode' + ); } if ((await this.members(party.id)).length > TEAM_SIZE[mode]) { - throw new MatchmakingError(409, 'party_too_big', `A ${mode} party has at most ${TEAM_SIZE[mode]} players`); + throw new MatchmakingError( + 409, + 'party_too_big', + `A ${mode} party has at most ${TEAM_SIZE[mode]} players` + ); } party.mode = mode; await db.runAsync('UPDATE mm_parties SET mode = ? WHERE id = ?', [mode, party.id]); @@ -448,12 +552,18 @@ export class MatchmakingService { await this.touchParty(party.id); if (party.leader_player_id === playerId) { // Players invited to a party that ends see the invite go. - const invited = await db.queryAsync<{ to_id: string }>('SELECT to_id FROM mm_party_invites WHERE party_id = ?', [party.id]); + const invited = await db.queryAsync<{ to_id: string }>( + 'SELECT to_id FROM mm_party_invites WHERE party_id = ?', + [party.id] + ); this.touch(...invited.map((r) => r.to_id)); await db.runAsync('DELETE FROM mm_parties WHERE id = ?', [party.id]); } else { await db.runAsync('DELETE FROM mm_queue_entries WHERE party_id = ?', [party.id]); - await db.runAsync('DELETE FROM mm_party_members WHERE party_id = ? AND player_id = ?', [party.id, playerId]); + await db.runAsync('DELETE FROM mm_party_members WHERE party_id = ? AND player_id = ?', [ + party.id, + playerId, + ]); } } @@ -483,7 +593,11 @@ export class MatchmakingService { if (existing) return existing; const members = await this.members(party.id); if (members.length > TEAM_SIZE[mode]) { - throw new MatchmakingError(409, 'party_too_big', `A ${mode} party has at most ${TEAM_SIZE[mode]} players`); + throw new MatchmakingError( + 409, + 'party_too_big', + `A ${mode} party has at most ${TEAM_SIZE[mode]} players` + ); } // Everyone must be set up for the game: it on their profile, and its account linked. const missing = await missingForGame(members, await this.partyGame(party.id)); @@ -494,7 +608,11 @@ export class MatchmakingService { for (const member of members) { const until = await this.cooldownUntil(member); if (until) { - throw new MatchmakingError(409, 'cooldown', `A party member can search again at ${new Date(until * 1000).toISOString()}`); + throw new MatchmakingError( + 409, + 'cooldown', + `A party member can search again at ${new Date(until * 1000).toISOString()}` + ); } } const entry: EntryRow = { @@ -606,23 +724,32 @@ export class MatchmakingService { [lobbyId, player, party.partyId, index + 1, party.queuedAt] ); } - await db.runAsync("UPDATE mm_queue_entries SET status = 'found', lobby_id = ? WHERE id = ?", [ - lobbyId, - party.entryId, - ]); + await db.runAsync( + "UPDATE mm_queue_entries SET status = 'found', lobby_id = ? WHERE id = ?", + [lobbyId, party.entryId] + ); } } this.touch(...group.flatMap((p) => p.players)); - log.info(`[MATCHMAKING] lobby ${lobbyId} (${mode}): ${group.length} parties, waiting for accepts`); + log.info( + `[MATCHMAKING] lobby ${lobbyId} (${mode}): ${group.length} parties, waiting for accepts` + ); return lobbyId; } // --- accept / decline ------------------------------------------------ - private async lobbyFor(playerId: string, lobbyId: string): Promise<{ lobby: LobbyRow; players: LobbyPlayerRow[] }> { - const lobby = await db.queryOneAsync('SELECT * FROM mm_lobbies WHERE id = ?', [lobbyId]); + private async lobbyFor( + playerId: string, + lobbyId: string + ): Promise<{ lobby: LobbyRow; players: LobbyPlayerRow[] }> { + const lobby = await db.queryOneAsync('SELECT * FROM mm_lobbies WHERE id = ?', [ + lobbyId, + ]); const players = lobby - ? await db.queryAsync('SELECT * FROM mm_lobby_players WHERE lobby_id = ?', [lobbyId]) + ? await db.queryAsync('SELECT * FROM mm_lobby_players WHERE lobby_id = ?', [ + lobbyId, + ]) : []; if (!lobby || !players.some((p) => p.player_id === playerId)) { throw new MatchmakingError(404, 'lobby_not_found', 'No such match for you'); @@ -634,7 +761,11 @@ export class MatchmakingService { return this.locked(async () => { const { lobby, players } = await this.lobbyFor(playerId, lobbyId); if (lobby.status !== 'accepting' || lobby.accept_deadline <= this.clock()) { - throw new MatchmakingError(409, 'not_accepting', 'This match is no longer waiting for answers'); + throw new MatchmakingError( + 409, + 'not_accepting', + 'This match is no longer waiting for answers' + ); } await db.runAsync( 'UPDATE mm_lobby_players SET accepted_at = ? WHERE lobby_id = ? AND player_id = ? AND accepted_at IS NULL', @@ -665,14 +796,17 @@ export class MatchmakingService { return this.locked(async () => { const { lobby, players } = await this.lobbyFor(playerId, lobbyId); if (lobby.status !== 'accepting') { - throw new MatchmakingError(409, 'not_accepting', 'This match is no longer waiting for answers'); + throw new MatchmakingError( + 409, + 'not_accepting', + 'This match is no longer waiting for answers' + ); } const now = this.clock(); - await db.runAsync('UPDATE mm_lobby_players SET declined_at = ? WHERE lobby_id = ? AND player_id = ?', [ - now, - lobbyId, - playerId, - ]); + await db.runAsync( + 'UPDATE mm_lobby_players SET declined_at = ? WHERE lobby_id = ? AND player_id = ?', + [now, lobbyId, playerId] + ); const party = players.find((p) => p.player_id === playerId)!.party_id; await this.penalize(playerId, 'decline', lobbyId); // The decliner's party leaves the queue; every other party goes back @@ -689,7 +823,9 @@ export class MatchmakingService { */ private async startMatch(lobby: LobbyRow, players: LobbyPlayerRow[]): Promise { if (!hasIntegration('cs2')) throw new Error('The CS2 module is not installed'); - const poolId = parseModePools(await db.getAppSettingAsync(MM_MODE_POOLS))[lobby.mode as MatchmakingMode] ?? null; + const poolId = + parseModePools(await db.getAppSettingAsync(MM_MODE_POOLS))[lobby.mode as MatchmakingMode] ?? + null; const pool = (await getIntegration('cs2').matchmakingMapPool?.(lobby.mode, poolId)) ?? []; if (pool.length === 0) throw new Error(`No CS2 maps for ${lobby.mode} matchmaking`); const map = pool[randomInt(pool.length)].id; @@ -703,7 +839,10 @@ export class MatchmakingService { ).map((r) => [r.id, r.name || r.id]) ); const team = (n: number) => { - const roster = players.filter((p) => p.team === n).map((p) => p.player_id).sort(); + const roster = players + .filter((p) => p.team === n) + .map((p) => p.player_id) + .sort(); return { name: `Team ${names.get(roster[0]) ?? n}`, players: Object.fromEntries(roster.map((id) => [id, names.get(id) ?? id])), @@ -711,7 +850,8 @@ export class MatchmakingService { }; const slug = `mm-${lobby.id.slice(0, 8)}`; - const teamSize = TEAM_SIZE[lobby.mode as MatchmakingMode]; + const mode = lobby.mode as MatchmakingMode; + const teamSize = TEAM_SIZE[mode]; await matchService.createMatch( { slug, @@ -723,17 +863,22 @@ export class MatchmakingService { team2: team(2), num_maps: 1, maplist: [map], - wingman: lobby.mode === '2v2', + wingman: mode === '2v2', + // The mode's own rules, never a tournament's: a 1v1 played the + // running tournament's MR6 (2026-10-12). + maxRounds: MODE_MAX_ROUNDS[mode], + ...matchmakingSides(mode), }, }, configuredPublicOrigin() ?? '', - resolveTournamentId() + null ); await db.runAsync("UPDATE matches SET source = 'matchmaking' WHERE slug = ?", [slug]); // The pool as it was, for the map roulette the players see: at most 30 // maps, the chosen one always among them. const chosen = pool.find((m) => m.id === map)!; - const shown = pool.length <= 30 ? pool : [...pool.filter((m) => m.id !== map).slice(0, 29), chosen]; + const shown = + pool.length <= 30 ? pool : [...pool.filter((m) => m.id !== map).slice(0, 29), chosen]; await db.runAsync('UPDATE mm_lobbies SET match_slug = ?, map = ?, map_pool = ? WHERE id = ?', [ slug, map, @@ -759,7 +904,12 @@ export class MatchmakingService { * loss. One per player per match. */ private async watchPresence(): Promise { - const rows = await db.queryAsync<{ lobby_id: string; match_slug: string; status: string; loaded_at: number | null }>( + const rows = await db.queryAsync<{ + lobby_id: string; + match_slug: string; + status: string; + loaded_at: number | null; + }>( `SELECT l.id AS lobby_id, l.match_slug, m.status, m.loaded_at FROM mm_lobbies l JOIN matches m ON m.slug = l.match_slug WHERE l.status = 'ready'` @@ -777,9 +927,10 @@ export class MatchmakingService { for (const p of playerConnectionService.getStatus(row.match_slug)?.connectedPlayers ?? []) { watch.lastSeen.set(p.steamId, now); } - const players = await db.queryAsync<{ player_id: string }>('SELECT player_id FROM mm_lobby_players WHERE lobby_id = ?', [ - row.lobby_id, - ]); + const players = await db.queryAsync<{ player_id: string }>( + 'SELECT player_id FROM mm_lobby_players WHERE lobby_id = ?', + [row.lobby_id] + ); const flagged = new Set( ( await db.queryAsync<{ player_id: string }>( @@ -825,7 +976,10 @@ export class MatchmakingService { * seed (not stored until their first rated match): their tournament rating's * mu, with a sigma of at least SEED_SIGMA. */ - private async ratingsFor(players: string[], mode: string): Promise> { + private async ratingsFor( + players: string[], + mode: string + ): Promise> { const out = new Map(); if (players.length === 0) return out; const marks = players.map(() => '?').join(', '); @@ -833,7 +987,13 @@ export class MatchmakingService { `SELECT player_id, mu, sigma, games, wins FROM mm_ratings WHERE game = 'cs2' AND mode = ? AND player_id IN (${marks})`, [mode, ...players] ); - for (const r of stored) out.set(r.player_id, { mu: Number(r.mu), sigma: Number(r.sigma), games: Number(r.games), wins: Number(r.wins) }); + for (const r of stored) + out.set(r.player_id, { + mu: Number(r.mu), + sigma: Number(r.sigma), + games: Number(r.games), + wins: Number(r.wins), + }); const missing = players.filter((p) => !out.has(p)); if (missing.length > 0) { // Seeded from the player's CS2 tournament rating. @@ -855,15 +1015,28 @@ export class MatchmakingService { */ applyMatchResult(matchSlug: string, winner: 'team1' | 'team2' | 'none'): Promise { return this.locked(async () => { - const lobby = await db.queryOneAsync('SELECT * FROM mm_lobbies WHERE match_slug = ?', [matchSlug]); + const lobby = await db.queryOneAsync( + 'SELECT * FROM mm_lobbies WHERE match_slug = ?', + [matchSlug] + ); if (!lobby) return false; - const already = await db.queryOneAsync<{ id: number }>('SELECT id FROM mm_rating_history WHERE match_slug = ? LIMIT 1', [ - matchSlug, - ]); + const already = await db.queryOneAsync<{ id: number }>( + 'SELECT id FROM mm_rating_history WHERE match_slug = ? LIMIT 1', + [matchSlug] + ); if (already) return false; - const players = await db.queryAsync('SELECT * FROM mm_lobby_players WHERE lobby_id = ?', [lobby.id]); - const team1 = players.filter((p) => p.team === 1).map((p) => p.player_id).sort(); - const team2 = players.filter((p) => p.team === 2).map((p) => p.player_id).sort(); + const players = await db.queryAsync( + 'SELECT * FROM mm_lobby_players WHERE lobby_id = ?', + [lobby.id] + ); + const team1 = players + .filter((p) => p.team === 1) + .map((p) => p.player_id) + .sort(); + const team2 = players + .filter((p) => p.team === 2) + .map((p) => p.player_id) + .sort(); const before = await this.ratingsFor([...team1, ...team2], lobby.mode); const ranks = winner === 'team1' ? [1, 2] : winner === 'team2' ? [2, 1] : [1, 1]; const [after1, after2] = osRate( @@ -881,7 +1054,11 @@ export class MatchmakingService { ) ).map((r) => r.player_id) ); - const write = async (ids: string[], after: Array<{ mu: number; sigma: number }>, won: boolean) => { + const write = async ( + ids: string[], + after: Array<{ mu: number; sigma: number }>, + won: boolean + ) => { const present = ids.filter((id) => !abandoned.has(id)).length; for (const [i, id] of ids.entries()) { const b = before.get(id)!; @@ -907,16 +1084,19 @@ export class MatchmakingService { await db.runAsync("UPDATE mm_lobbies SET status = 'finished' WHERE id = ?", [lobby.id]); this.touch(...players.map((p) => p.player_id)); await progressionService.awardMatchXp(matchSlug, winner, abandoned); - log.info(`[MATCHMAKING] ${matchSlug} rated (${winner === 'none' ? 'draw' : `${winner} won`})`); + log.info( + `[MATCHMAKING] ${matchSlug} rated (${winner === 'none' ? 'draw' : `${winner} won`})` + ); return true; }); } /** Time ran out: who did not answer gets a no-show; parties that all accepted go back to the front. */ private async resolveTimeout(lobby: LobbyRow): Promise { - const players = await db.queryAsync('SELECT * FROM mm_lobby_players WHERE lobby_id = ?', [ - lobby.id, - ]); + const players = await db.queryAsync( + 'SELECT * FROM mm_lobby_players WHERE lobby_id = ?', + [lobby.id] + ); const silentParties = new Set(); for (const p of players) { if (p.accepted_at === null) { @@ -924,19 +1104,33 @@ export class MatchmakingService { await this.penalize(p.player_id, 'no_show', lobby.id); } } - for (const party of silentParties) await db.runAsync('DELETE FROM mm_queue_entries WHERE party_id = ?', [party]); + for (const party of silentParties) + await db.runAsync('DELETE FROM mm_queue_entries WHERE party_id = ?', [party]); await this.cancel(lobby.id, 'timeout'); } private async cancel(lobbyId: string, reason: string): Promise { - const affected = await db.queryAsync<{ player_id: string }>('SELECT player_id FROM mm_lobby_players WHERE lobby_id = ?', [lobbyId]); + const affected = await db.queryAsync<{ player_id: string }>( + 'SELECT player_id FROM mm_lobby_players WHERE lobby_id = ?', + [lobbyId] + ); this.touch(...affected.map((p) => p.player_id)); - await db.runAsync("UPDATE mm_lobbies SET status = 'cancelled', cancel_reason = ? WHERE id = ?", [reason, lobbyId]); - await db.runAsync("UPDATE mm_queue_entries SET status = 'searching', lobby_id = NULL WHERE lobby_id = ?", [lobbyId]); + await db.runAsync( + "UPDATE mm_lobbies SET status = 'cancelled', cancel_reason = ? WHERE id = ?", + [reason, lobbyId] + ); + await db.runAsync( + "UPDATE mm_queue_entries SET status = 'searching', lobby_id = NULL WHERE lobby_id = ?", + [lobbyId] + ); log.info(`[MATCHMAKING] lobby ${lobbyId} cancelled (${reason})`); } - private async penalize(playerId: string, kind: 'decline' | 'no_show', lobbyId: string): Promise { + private async penalize( + playerId: string, + kind: 'decline' | 'no_show', + lobbyId: string + ): Promise { this.touch(playerId); const now = this.clock(); const row = await db.queryOneAsync<{ n: number | string }>( @@ -969,7 +1163,9 @@ export class MatchmakingService { const members = party ? await this.members(party.id) : []; const entry = party ? await this.entryOf(party.id) : undefined; let lobby: MatchmakingMe['lobby'] = null; - const lobbyRow = await db.queryOneAsync( + const lobbyRow = await db.queryOneAsync< + LobbyRow & { team: number; accepted_at: number | null } + >( `SELECT l.*, lp.team, lp.accepted_at FROM mm_lobbies l JOIN mm_lobby_players lp ON lp.lobby_id = l.id WHERE lp.player_id = ? AND l.status IN ('accepting', 'ready') @@ -1003,16 +1199,24 @@ export class MatchmakingService { members, people: await this.people(members), invited: await this.people( - (await db.queryAsync<{ to_id: string }>('SELECT to_id FROM mm_party_invites WHERE party_id = ? ORDER BY created_at', [party.id])).map( - (r) => r.to_id - ) + ( + await db.queryAsync<{ to_id: string }>( + 'SELECT to_id FROM mm_party_invites WHERE party_id = ? ORDER BY created_at', + [party.id] + ) + ).map((r) => r.to_id) ), } : null, - notReady: await this.notReady(party ? members : [playerId], party ? await this.partyGame(party.id) : 'cs2'), + notReady: await this.notReady( + party ? members : [playerId], + party ? await this.partyGame(party.id) : 'cs2' + ), invites: await this.invitesTo(playerId), queueCounts: await this.queueCounts(), - queue: entry ? { mode: entry.mode, queuedAt: Number(entry.queued_at), status: entry.status } : null, + queue: entry + ? { mode: entry.mode, queuedAt: Number(entry.queued_at), status: entry.status } + : null, lobby, cooldownUntil: await this.cooldownUntil(playerId), rating: await this.ratingSummary(playerId, entry?.mode ?? party?.mode ?? '5v5'), @@ -1050,19 +1254,34 @@ export class MatchmakingService { /** Rounds per half and the map pool's name per mode, for the mode cards. */ private async modeRules(): Promise> { const chosen = parseModePools(await db.getAppSettingAsync(MM_MODE_POOLS)); - const pools = hasIntegration('cs2') ? ((await getIntegration('cs2').matchmakingPools?.()) ?? []) : []; - const name = (id: number | undefined) => (id ? (pools.find((p) => p.id === id)?.name ?? null) : null); - return Object.fromEntries(MODES.map((mode) => [mode, { maxRounds: MODE_MAX_ROUNDS[mode], pool: name(chosen[mode]) }])); + const pools = hasIntegration('cs2') + ? ((await getIntegration('cs2').matchmakingPools?.()) ?? []) + : []; + const name = (id: number | undefined) => + id ? (pools.find((p) => p.id === id)?.name ?? null) : null; + return Object.fromEntries( + MODES.map((mode) => [mode, { maxRounds: MODE_MAX_ROUNDS[mode], pool: name(chosen[mode]) }]) + ); } - private async people(ids: string[]): Promise> { + private async people( + ids: string[] + ): Promise> { if (ids.length === 0) return []; - const rows = await db.queryAsync<{ id: string; name: string | null; avatar_url: string | null }>( + const rows = await db.queryAsync<{ + id: string; + name: string | null; + avatar_url: string | null; + }>( `SELECT id, name, avatar_url FROM players WHERE id IN (${ids.map(() => '?').join(', ')})`, ids ); const byId = new Map(rows.map((r) => [r.id, r])); - return ids.map((id) => ({ id, name: byId.get(id)?.name || id, avatarUrl: byId.get(id)?.avatar_url ?? null })); + return ids.map((id) => ({ + id, + name: byId.get(id)?.name || id, + avatarUrl: byId.get(id)?.avatar_url ?? null, + })); } private async queueCounts(): Promise> { @@ -1088,7 +1307,10 @@ export class MatchmakingService { } /** The match room: teams with names, map and the match, for a player in the lobby. */ - async lobbyView(playerId: string, lobbyId: string): Promise<{ + async lobbyView( + playerId: string, + lobbyId: string + ): Promise<{ id: string; status: string; mode: string; @@ -1101,7 +1323,13 @@ export class MatchmakingService { team: number; /** The side this team starts on, from the match config; null for a knife round or before the match exists. */ startSide: 'CT' | 'T' | null; - players: Array<{ id: string; name: string; avatarUrl: string | null; accepted: boolean; inServer: boolean }>; + players: Array<{ + id: string; + name: string; + avatarUrl: string | null; + accepted: boolean; + inServer: boolean; + }>; }>; /** Players on the server right now, of `total`. */ inServer: number; @@ -1110,37 +1338,56 @@ export class MatchmakingService { autostartMinutes: number; }> { const { lobby, players } = await this.lobbyFor(playerId, lobbyId); - const row = lobby as LobbyRow & { map: string | null; match_slug: string | null; map_pool: string | null }; + const row = lobby as LobbyRow & { + map: string | null; + match_slug: string | null; + map_pool: string | null; + }; let mapPool: Array<{ id: string; name: string; imageUrl: string | null }> = []; try { mapPool = row.map_pool ? JSON.parse(row.map_pool) : []; } catch { mapPool = []; } - const names = await db.queryAsync<{ id: string; name: string | null; avatar_url: string | null }>( + const names = await db.queryAsync<{ + id: string; + name: string | null; + avatar_url: string | null; + }>( `SELECT id, name, avatar_url FROM players WHERE id IN (${players.map(() => '?').join(', ')})`, players.map((p) => p.player_id) ); const nameOf = new Map(names.map((n) => [n.id, n.name || n.id])); const avatarOf = new Map(names.map((n) => [n.id, n.avatar_url])); const match = row.match_slug - ? await db.queryOneAsync<{ status: string; config: string | null }>('SELECT status, config FROM matches WHERE slug = ?', [row.match_slug]) + ? await db.queryOneAsync<{ status: string; config: string | null }>( + 'SELECT status, config FROM matches WHERE slug = ?', + [row.match_slug] + ) : undefined; // Who is on the server (the game reports joins), and the sides from the match config. const connected = new Set( - (row.match_slug ? playerConnectionService.getStatus(row.match_slug)?.connectedPlayers ?? [] : []).map((c) => c.steamId) + (row.match_slug + ? (playerConnectionService.getStatus(row.match_slug)?.connectedPlayers ?? []) + : [] + ).map((c) => c.steamId) ); let team1Side: 'CT' | 'T' | null = null; try { - const sides = match?.config ? (JSON.parse(match.config) as { map_sides?: string[] }).map_sides : undefined; + const sides = match?.config + ? (JSON.parse(match.config) as { map_sides?: string[] }).map_sides + : undefined; team1Side = sides?.[0] === 'team1_ct' ? 'CT' : sides?.[0] === 'team2_ct' ? 'T' : null; } catch { team1Side = null; } const sideOf = (team: number): 'CT' | 'T' | null => team1Side === null ? null : team === 1 ? team1Side : team1Side === 'CT' ? 'T' : 'CT'; - const autostartRaw = Number(await settingsService.getSetting('at_autostart_after_minutes').catch(() => null)); - const autostartMinutes = Number.isInteger(autostartRaw) && autostartRaw > 0 ? Math.min(autostartRaw, 120) : 0; + const autostartRaw = Number( + await settingsService.getSetting('at_autostart_after_minutes').catch(() => null) + ); + const autostartMinutes = + Number.isInteger(autostartRaw) && autostartRaw > 0 ? Math.min(autostartRaw, 120) : 0; return { id: lobby.id, status: lobby.status, @@ -1170,9 +1417,31 @@ export class MatchmakingService { /** Admin: who is searching, open lobbies, and penalties of the last 24 h. */ async adminQueue(): Promise<{ - searching: Array<{ partyId: string; mode: string; waited: number; players: Array<{ id: string; name: string }> }>; - lobbies: Array<{ id: string; mode: string; status: string; matchSlug: string | null; map: string | null; accepted: number; total: number; createdAt: number }>; - penalties: Array<{ id: number; playerId: string; name: string; kind: string; cooldownUntil: number; createdAt: number; cleared: boolean }>; + searching: Array<{ + partyId: string; + mode: string; + waited: number; + players: Array<{ id: string; name: string }>; + }>; + lobbies: Array<{ + id: string; + mode: string; + status: string; + matchSlug: string | null; + map: string | null; + accepted: number; + total: number; + createdAt: number; + }>; + penalties: Array<{ + id: number; + playerId: string; + name: string; + kind: string; + cooldownUntil: number; + createdAt: number; + cleared: boolean; + }>; }> { const now = this.clock(); const nameOf = async (ids: string[]) => { @@ -1183,7 +1452,9 @@ export class MatchmakingService { ); return new Map(rows.map((r) => [r.id, r.name || r.id])); }; - const entries = await db.queryAsync("SELECT * FROM mm_queue_entries WHERE status = 'searching' ORDER BY queued_at"); + const entries = await db.queryAsync( + "SELECT * FROM mm_queue_entries WHERE status = 'searching' ORDER BY queued_at" + ); const searching = []; for (const e of entries) { const ids = await this.members(e.party_id); @@ -1195,7 +1466,9 @@ export class MatchmakingService { players: ids.map((id) => ({ id, name: names.get(id) ?? id })), }); } - const lobbyRows = await db.queryAsync( + const lobbyRows = await db.queryAsync< + LobbyRow & { match_slug: string | null; map: string | null; created_at: number } + >( "SELECT * FROM mm_lobbies WHERE status IN ('accepting', 'ready') ORDER BY created_at DESC LIMIT 50" ); const lobbies = []; @@ -1215,7 +1488,14 @@ export class MatchmakingService { createdAt: Number(l.created_at), }); } - const pen = await db.queryAsync<{ id: number; player_id: string; kind: string; cooldown_until: number; created_at: number; cleared_by: string | null }>( + const pen = await db.queryAsync<{ + id: number; + player_id: string; + kind: string; + cooldown_until: number; + created_at: number; + cleared_by: string | null; + }>( 'SELECT id, player_id, kind, cooldown_until, created_at, cleared_by FROM mm_penalties WHERE created_at > ? ORDER BY created_at DESC LIMIT 100', [now - 86400] ); @@ -1256,12 +1536,16 @@ export class MatchmakingService { /** Boot: lobbies left accepting by a restart are cancelled without penalty; then the loop starts. */ async start(): Promise { await this.locked(async () => { - const open = await db.queryAsync<{ id: string }>("SELECT id FROM mm_lobbies WHERE status = 'accepting'"); + const open = await db.queryAsync<{ id: string }>( + "SELECT id FROM mm_lobbies WHERE status = 'accepting'" + ); for (const lobby of open) await this.cancel(lobby.id, 'restart'); }); if (this.timer) return; this.timer = setInterval(() => { - void this.tick().catch((error) => log.warn('[MATCHMAKING] loop failed', { error: (error as Error).message })); + void this.tick().catch((error) => + log.warn('[MATCHMAKING] loop failed', { error: (error as Error).message }) + ); }, LOOP_MS); this.timer.unref?.(); } diff --git a/api/src/services/matchmaking/rules.ts b/api/src/services/matchmaking/rules.ts index c4e852ed0..d97e98a14 100644 --- a/api/src/services/matchmaking/rules.ts +++ b/api/src/services/matchmaking/rules.ts @@ -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; } @@ -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, @@ -178,7 +183,8 @@ export function validateSearchWindow( input: unknown ): { start: number; step: number; cap: number; uncappedAfterMinutes: number | null } | null { const v = (input ?? {}) as Record; - 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; @@ -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); @@ -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) + ); } /** @@ -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; @@ -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; @@ -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); @@ -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]; @@ -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); } diff --git a/client/src/locales/de/translation/misc.json b/client/src/locales/de/translation/misc.json index 010fe26bb..7e6a1b218 100644 --- a/client/src/locales/de/translation/misc.json +++ b/client/src/locales/de/translation/misc.json @@ -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}}", diff --git a/client/src/locales/en/translation/misc.json b/client/src/locales/en/translation/misc.json index ecbcd3984..18b5cda42 100644 --- a/client/src/locales/en/translation/misc.json +++ b/client/src/locales/en/translation/misc.json @@ -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", @@ -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}}", diff --git a/client/src/locales/es/translation/misc.json b/client/src/locales/es/translation/misc.json index 74643a9ce..b42f5e0e2 100644 --- a/client/src/locales/es/translation/misc.json +++ b/client/src/locales/es/translation/misc.json @@ -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}}", diff --git a/client/src/locales/fr/translation/misc.json b/client/src/locales/fr/translation/misc.json index d5c908fc2..49a0f3969 100644 --- a/client/src/locales/fr/translation/misc.json +++ b/client/src/locales/fr/translation/misc.json @@ -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}}", diff --git a/client/src/locales/it/translation/misc.json b/client/src/locales/it/translation/misc.json index 37c722cfe..d84e9d771 100644 --- a/client/src/locales/it/translation/misc.json +++ b/client/src/locales/it/translation/misc.json @@ -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}}", diff --git a/client/src/locales/nb/translation/misc.json b/client/src/locales/nb/translation/misc.json index 01d4d5be4..ecc2b5233 100644 --- a/client/src/locales/nb/translation/misc.json +++ b/client/src/locales/nb/translation/misc.json @@ -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", @@ -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}}", diff --git a/client/src/locales/nl/translation/misc.json b/client/src/locales/nl/translation/misc.json index bded2563d..f0debcf4e 100644 --- a/client/src/locales/nl/translation/misc.json +++ b/client/src/locales/nl/translation/misc.json @@ -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}}", diff --git a/client/src/locales/pl/translation/misc.json b/client/src/locales/pl/translation/misc.json index 28f1dd1b5..dc05b5bd2 100644 --- a/client/src/locales/pl/translation/misc.json +++ b/client/src/locales/pl/translation/misc.json @@ -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}}", diff --git a/client/src/locales/pt-PT/translation/misc.json b/client/src/locales/pt-PT/translation/misc.json index e28c8c62e..7dffc282b 100644 --- a/client/src/locales/pt-PT/translation/misc.json +++ b/client/src/locales/pt-PT/translation/misc.json @@ -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}}", diff --git a/client/src/locales/zh-CN/translation/misc.json b/client/src/locales/zh-CN/translation/misc.json index 990be0cde..4a808144d 100644 --- a/client/src/locales/zh-CN/translation/misc.json +++ b/client/src/locales/zh-CN/translation/misc.json @@ -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}}", diff --git a/client/src/pages/Play.tsx b/client/src/pages/Play.tsx index a6c2f78fc..ed3d7784b 100644 --- a/client/src/pages/Play.tsx +++ b/client/src/pages/Play.tsx @@ -324,7 +324,8 @@ export default function Play() { // The leader picks the party's mode (saved, so everyone in it sees it); a // party can't pick a mode it's too big for, and members only watch. const partySize = party?.members.length ?? 1; - const modeOpen = (m: string) => m === mode || (isLeader && !searching && partySize <= teamSizeOf(m)); + const modeOpen = (m: string) => + m === mode || (isLeader && !searching && partySize <= teamSizeOf(m)); const pickMode = (m: string) => { if (m === mode || busy) return; if (party) void run(() => matchmakingAction('PUT', '/party/mode', { mode: m })); @@ -479,8 +480,19 @@ export default function Play() { ); })} - - {t('matchmaking.play.seats', { count: inQueue, total: need })} + + {/* What the search is doing (services/matchmaking/rules.ts + fewSearching): with fewer than two matches' worth + searching, anyone is matched; otherwise by rating. */} + {t( + (counts[queueMode] ?? inQueue) < need * 2 + ? 'matchmaking.play.searchAnyone' + : 'matchmaking.play.searchRating', + { count: counts[queueMode] ?? inQueue, total: need } + )} {typeof me?.waitSeconds?.[queueMode] === 'number' && ( - + {inv.from.name.trim()[0]?.toUpperCase()} @@ -525,10 +547,20 @@ export default function Play() { {t('social.notice.partyDetail', { mode: inv.mode, size: inv.size })} - - @@ -579,7 +611,16 @@ export default function Play() { fontSize: textSize.sm, }} > - + {p.name.trim()[0]?.toUpperCase()} {t('social.invite.chip', { name: p.name })} @@ -621,10 +662,15 @@ export default function Play() { invited={invited.map((p) => p.id)} inviteLink={party ? inviteLink : null} busy={busy} - onMakeLink={() => void run(() => matchmakingAction('POST', '/party', { mode }))} + onMakeLink={() => + void run(() => matchmakingAction('POST', '/party', { mode })) + } onJoinCode={(c) => { setInviteAnchor(null); - void run(() => matchmakingAction('POST', '/party/join', { code: c }), t('matchmaking.party.joined')); + void run( + () => matchmakingAction('POST', '/party/join', { code: c }), + t('matchmaking.party.joined') + ); }} /> @@ -771,17 +817,43 @@ export default function Play() { {notReady.length > 0 && ( - - {t('social.setup.title')} + + + {t('social.setup.title')} + {notReady.map((p) => ( {p.id === playerSteamId - ? t(p.missing === 'game' ? 'social.setup.youGame' : 'social.setup.youAccount') - : t(p.missing === 'game' ? 'social.setup.theyGame' : 'social.setup.theyAccount', { name: p.name })} + ? t( + p.missing === 'game' + ? 'social.setup.youGame' + : 'social.setup.youAccount' + ) + : t( + p.missing === 'game' + ? 'social.setup.theyGame' + : 'social.setup.theyAccount', + { name: p.name } + )} ))} {notReady.some((p) => p.id === playerSteamId) && ( - )} @@ -795,7 +867,11 @@ export default function Play() {