Files
Krunker-Civilian-Client-Test/src/preload/matchmaker.ts
T
bigjakk 60d4d5bb47 v0.5.6 — Ping-sorted matchmaker with auto-join
Matchmaker now sorts filtered lobbies by lowest ping then highest
player count instead of picking randomly. New auto-join toggle skips
the popup and navigates directly to the best match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:48:54 -08:00

251 lines
9.6 KiB
TypeScript

// ── Custom Matchmaker (ported from Crankshaft) ──
// Fetches live lobby list from matchmaker.krunker.io, filters by user criteria,
// sorts by lowest ping then highest player count, and joins the best match.
import { ipcRenderer } from 'electron';
import type { Keybind } from '../main/config';
import type { SavedConsole } from './utils';
export const MATCHMAKER_GAMEMODES = ['Free for All', 'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Parkour', 'Hide & Seek', 'Infected', 'Race', 'Last Man Standing', 'Simon Says', 'Gun Game', 'Prop Hunt', 'Boss Hunt', 'Classic FFA', 'Deposit', 'Stalker', 'King of the Hill', 'One in the Chamber', 'Trade', 'Kill Confirmed', 'Defuse', 'Sharp Shooter', 'Traitor', 'Raid', 'Blitz', 'Domination', 'Squad Deathmatch', 'Kranked FFA', 'Team Defender', 'Deposit FFA', 'Chaos Snipers', 'Bighead FFA'];
export const MATCHMAKER_REGIONS = ['MBI', 'NY', 'FRA', 'SIN', 'DAL', 'SYD', 'MIA', 'BHN', 'TOK', 'BRZ', 'AFR', 'LON', 'CHI', 'SV', 'STL', 'MX'];
export const MATCHMAKER_REGION_NAMES: Record<string, string> = { MBI: 'Mumbai', NY: 'New York', FRA: 'Frankfurt', SIN: 'Singapore', DAL: 'Dallas', SYD: 'Sydney', MIA: 'Miami', BHN: 'Middle East', TOK: 'Tokyo', BRZ: 'Brazil', AFR: 'South Africa', LON: 'London', CHI: 'China', SV: 'Silicon Valley', STL: 'Seattle', MX: 'Mexico' };
const MAP_ICON_INDICES = ['Burg', 'Littletown', 'Sandstorm', 'Subzero', 'Undergrowth', 'Shipment', 'Freight', 'Lostworld', 'Citadel', 'Oasis', 'Kanji', 'Industry', 'Lumber', 'Evacuation', 'Site', 'SkyTemple', 'Lagoon', 'Bureau', 'Tortuga', 'Tropicano', 'Krunk_Plaza', 'Arena', 'Habitat', 'Atomic', 'Old_Burg', 'Throwback', 'Stockade', 'Facility', 'Clockwork', 'Laboratory', 'Shipyard', 'Soul Sanctum', 'Bazaar', 'Erupt', 'HQ', 'Khepri', 'Lush', 'Vivo', 'Slide Moonlight', 'Eterno Sim'];
interface MatchmakerGame {
gameID: string;
region: string;
playerCount: number;
playerLimit: number;
map: string;
gamemode: string;
remainingTime: number;
}
export interface MatchmakerConfig {
enabled: boolean;
regions: string[];
gamemodes: string[];
minPlayers: number;
maxPlayers: number;
minRemainingTime: number;
openServerBrowser: boolean;
autoJoin: boolean;
acceptKey: Keybind;
cancelKey: Keybind;
}
function secondsToTimestring(num: number): string {
const minutes = Math.floor(num / 60);
const seconds = num % 60;
if (minutes < 1) return `${num}s`;
return `${minutes}m ${seconds}s`;
}
// ── Popup DOM (lazy-initialized on first use) ──
const POPUP_ID = 'matchmakerPopupContainer';
interface PopupDOM {
element: HTMLDivElement;
title: HTMLDivElement;
description: HTMLDivElement;
confirmBtn: HTMLDivElement;
cancelBtn: HTMLDivElement;
}
let _popup: PopupDOM | null = null;
function getPopup(): PopupDOM {
if (_popup) return _popup;
const element = document.createElement('div');
element.id = POPUP_ID;
const title = document.createElement('div');
title.id = 'matchmakerPopupTitle';
element.appendChild(title);
const description = document.createElement('div');
description.id = 'matchmakerPopupDescription';
element.appendChild(description);
const options = document.createElement('div');
options.id = 'matchmakerPopupOptions';
const confirmBtn = document.createElement('div');
confirmBtn.id = 'matchmakerConfirmButton';
confirmBtn.className = 'matchmakerPopupButton bigShadowT';
confirmBtn.textContent = 'Join';
confirmBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); });
confirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
const cancelBtn = document.createElement('div');
cancelBtn.id = 'matchmakerCancelButton';
cancelBtn.className = 'matchmakerPopupButton bigShadowT';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); });
cancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
options.appendChild(confirmBtn);
options.appendChild(cancelBtn);
element.appendChild(options);
_popup = { element, title, description, confirmBtn, cancelBtn };
return _popup;
}
// ── State ──
let popupGameID = '';
let openServerBrowser = true;
let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false };
let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false };
function decideMatchmakerDecision(accept: boolean): void {
const w = window as any;
if (typeof w.playSelect === 'function') w.playSelect();
if (accept && popupGameID !== 'none') {
window.location.href = `https://krunker.io/?game=${popupGameID}`;
} else {
const popup = getPopup();
if (popup.element.parentNode) popup.element.remove();
if (popupGameID === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
w.openServerWindow(0);
}
}
}
function matchesKey(bind: Keybind, event: KeyboardEvent): boolean {
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
return event.key === bind.key
&& event.shiftKey === bind.shift
&& event.altKey === bind.alt
&& event.ctrlKey === bind.ctrl;
}
function handleMatchmakerBind(event: KeyboardEvent): void {
if (document.pointerLockElement) return;
const isAccept = matchesKey(confirmKey, event);
const isCancel = matchesKey(cancelKey, event);
if (isAccept || isCancel) {
document.removeEventListener('keydown', handleMatchmakerBind, true);
decideMatchmakerDecision(isAccept);
}
}
function createFetchedGamePopup(game: MatchmakerGame): void {
const popup = getPopup();
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
popup.element.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
popupGameID = game.gameID;
if (game.gameID === 'none') {
popup.title.textContent = 'No Games Found...';
popup.description.textContent = 'Check the server browser to see other lobbies.';
popup.confirmBtn.style.display = 'none';
} else {
popup.title.textContent = 'Game Found!';
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
popup.description.textContent = '';
popup.description.appendChild(document.createTextNode(
`${game.gamemode} on ${game.map} (${regionName})`
));
popup.description.appendChild(document.createElement('br'));
popup.description.appendChild(document.createTextNode(
`${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`
));
popup.confirmBtn.style.display = 'block';
}
document.addEventListener('keydown', handleMatchmakerBind, true);
const uiBase = document.getElementById('uiBase');
if (uiBase) uiBase.appendChild(popup.element);
}
async function fetchAndFilterGames(mmConfig: MatchmakerConfig): Promise<MatchmakerGame[]> {
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
const result = await response.json();
const games: MatchmakerGame[] = [];
for (const game of result.games) {
const gameID: string = game[0];
const region = gameID.split(':')[0];
const playerCount: number = game[2];
const playerLimit: number = game[3];
const map: string = game[4].i;
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
const remainingTime: number = game[5];
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) continue;
if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) continue;
if (playerCount < mmConfig.minPlayers) continue;
if (playerCount > mmConfig.maxPlayers) continue;
if (remainingTime < mmConfig.minRemainingTime) continue;
if (playerCount === playerLimit) continue;
if (window.location.href.includes(gameID)) continue;
games.push({ gameID, region, playerCount, playerLimit, map, gamemode, remainingTime });
}
return games;
}
function sortByPingThenPlayers(games: MatchmakerGame[], pings: Record<string, number>): MatchmakerGame[] {
return games.sort((a, b) => {
const pingA = pings[a.region] ?? 999;
const pingB = pings[b.region] ?? 999;
if (pingA !== pingB) return pingA - pingB;
return b.playerCount - a.playerCount;
});
}
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
openServerBrowser = mmConfig.openServerBrowser;
confirmKey = mmConfig.acceptKey;
cancelKey = mmConfig.cancelKey;
// Dismiss existing popup if active
if (document.getElementById(POPUP_ID)) decideMatchmakerDecision(false);
_con?.log('[KCC-MM] Fetching game list + pings...');
const [games, pings] = await Promise.all([
fetchAndFilterGames(mmConfig),
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
]);
_con?.log('[KCC-MM]', games.length, 'games passed filters, pings:', pings);
if (games.length > 0) {
sortByPingThenPlayers(games, pings);
const best = games[0];
_con?.log('[KCC-MM] Best match:', best.gameID, best.region, best.map, `(${pings[best.region] ?? '?'}ms)`);
if (mmConfig.autoJoin) {
window.location.href = `https://krunker.io/?game=${best.gameID}`;
return;
}
createFetchedGamePopup(best);
} else {
_con?.log('[KCC-MM] No matching games found');
if (mmConfig.autoJoin) {
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
(window as any).openServerWindow(0);
}
return;
}
createFetchedGamePopup({
gameID: 'none',
region: 'none',
playerCount: 0,
playerLimit: 0,
map: MAP_ICON_INDICES[0],
gamemode: MATCHMAKER_GAMEMODES[0],
remainingTime: 0,
});
}
}