feat: add external ranked queue

This commit is contained in:
2026-04-04 10:46:39 -07:00
parent c915fff113
commit 71aaee391f
5 changed files with 575 additions and 1 deletions
+17
View File
@@ -706,6 +706,23 @@ export const RANK_TRACKER_CSS = `
.kpc-rank-grid-item img { width: 28px; height: 28px; }
.kpc-rank-name { font-size: 13px; font-weight: 600; }
.kpc-rank-elo { font-size: 11px; color: #888; }
/* Ranked queue button in ranked menu footer */
#kpc-ranked-queue-btn {
background-color: #5ce05a;
color: #fff;
border: none;
border-radius: 9px;
padding: 12px 14px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
#kpc-ranked-queue-btn:hover { background-color: #4bc94a; }
`;
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
+6
View File
@@ -15,6 +15,7 @@ import { showUpdateWindow } from './update-window';
import { DiscordRPC } from './discord-rpc';
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
import { TabManager } from './tab-manager';
import { openRankedQueue } from './ranked-queue';
// ── App version for API calls ──
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -651,6 +652,11 @@ async function launchApp(): Promise<void> {
}
});
// ── Ranked queue IPC handler ──
ipcMain.on('open-ranked-queue', (_e, token: string, region: string, allRegions: boolean) => {
openRankedQueue(token, region, allRegions);
});
// ── Discord Rich Presence IPC handler ──
ipcMain.on('discord-update', (_e, activity: any) => {
discordRpc?.setActivity(activity);
File diff suppressed because one or more lines are too long
+516
View File
@@ -0,0 +1,516 @@
import { BrowserWindow } from 'electron';
import { QUEUE_NOTIFICATION_AUDIO } from './ranked-queue-audio';
let queueWindow: BrowserWindow | null = null;
const RANKED_QUEUE_WS = 'wss://gamefrontend.svc.krunker.io/v1/matchmaking/queue';
const RANKED_MAPS: Record<string, { number: number; image: string }> = {
sandstorm_v3: { number: 2, image: 'https://assets.krunker.io/img/maps/map_2.png' },
undergrowth: { number: 4, image: 'https://assets.krunker.io/img/maps/map_4.png' },
industry: { number: 11, image: 'https://assets.krunker.io/img/maps/map_11.png' },
site: { number: 14, image: 'https://assets.krunker.io/img/maps/map_14.png' },
bureau: { number: 17, image: 'https://assets.krunker.io/img/maps/map_17.png' },
burg_new: { number: 0, image: 'https://assets.krunker.io/img/maps/map_0.png' },
eterno_sim: { number: 39, image: 'https://assets.krunker.io/img/maps/map_39.png' },
};
const RANKED_REGIONS: Record<string, string> = {
na: 'North America',
eu: 'Europe',
as: 'Asia',
};
const QUEUE_CSS = `
* { user-select: none; margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Trebuchet MS", sans-serif;
background: #0d0d0d;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #e0e0e0;
overflow: hidden;
}
.queuer-container {
position: relative;
background: #1a1a1a;
padding: 40px 52px;
max-width: 1000px;
width: 90vw;
border: 2px solid #2a2a2a;
border-top: 3px solid #06b6d4;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.7);
border-radius: 4px;
}
.main-content { display: flex; align-items: center; gap: 56px; }
.left-section { flex: 1; display: flex; flex-direction: column; gap: 24px; }
.status-area {
display: flex; align-items: center; gap: 14px;
position: relative; padding-left: 18px;
}
.status-area::before {
content: ""; position: absolute; left: 0;
width: 8px; height: 8px; background: #666;
border-radius: 50%; transition: background 0.3s ease;
}
.status-area.active::before {
background: #06b6d4;
box-shadow: 0 0 12px rgba(6, 182, 212, 0.6);
}
#queueStatus {
font-size: 14px; font-weight: 600; color: #666;
text-transform: uppercase; letter-spacing: 1px;
transition: color 0.3s ease;
}
#queueStatus.active { color: #06b6d4; }
.timer-display {
font-size: 52px; font-weight: 700; color: #fff;
font-variant-numeric: tabular-nums; letter-spacing: 0.5px;
padding: 12px 16px; background: #222;
border-left: 3px solid #06b6d4; border-radius: 2px;
}
.region-controls { display: flex; gap: 12px; }
.region-option { position: relative; }
.region-option input { display: none; }
.region-option label {
display: block; padding: 12px 24px; background: #222;
border: 2px solid #2d2d2d; border-radius: 4px;
color: #888; font-size: 16px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
cursor: pointer; transition: all 0.2s ease;
}
.region-option label:hover { background: #2a2a2a; border-color: #3a3a3a; }
.region-option input:checked + label {
background: rgba(6, 182, 212, 0.1);
border-color: #06b6d4; color: #06b6d4;
}
.divider { width: 1px; height: 120px; background: #2a2a2a; }
.right-section { display: flex; flex-direction: column; gap: 14px; }
.btn {
padding: 16px 42px; border: 2px solid transparent;
font-size: 20px; font-weight: 600; cursor: pointer;
transition: all 0.2s ease; font-family: "Trebuchet MS", sans-serif;
text-transform: uppercase; letter-spacing: 1px; border-radius: 4px;
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary { background: #222; color: #999; border-color: #2a2a2a; }
.btn-secondary:hover:not(:disabled) { background: #2a2a2a; border-color: #3a3a3a; }
.btn-primary { background: #06b6d4; color: #fff; border-color: #06b6d4; }
.btn-primary:hover:not(:disabled) { background: #0ea5ca; border-color: #0ea5ca; }
.btn-primary:active:not(:disabled) { transform: scale(0.98); }
.btn-primary.in-queue { background: #222; border-color: #06b6d4; color: #06b6d4; }
.btn-primary.in-queue:hover:not(:disabled) { background: rgba(6, 182, 212, 0.1); }
.overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.9); display: flex;
align-items: center; justify-content: center;
opacity: 0; visibility: hidden; transition: all 0.3s ease; z-index: 1000;
}
.overlay.active { opacity: 1; visibility: visible; }
.popup {
background: #1a1a1a; border: 2px solid #2a2a2a;
border-top: 3px solid #06b6d4; max-width: 560px; width: 90vw;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.8);
text-align: center; transform: scale(0.95);
transition: transform 0.3s ease; border-radius: 4px;
}
.overlay.active .popup { transform: scale(1); }
.popup h2 {
margin-top: 12px; font-size: 32px; font-weight: 700;
color: #06b6d4; text-transform: uppercase; letter-spacing: 1.5px;
}
.popup-content { margin: 20px 0; }
.popup-content p {
font-size: 15px; color: #888; margin-bottom: 12px;
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;
}
.region-found {
font-size: 18px; font-weight: 700; color: #fff;
text-transform: uppercase; letter-spacing: 1px;
display: inline-block; padding: 12px 24px;
background: rgba(6, 182, 212, 0.15);
border: 2px solid #06b6d4; border-radius: 4px;
}
.countdown-large {
font-size: 48px; font-weight: 700; color: #06b6d4;
margin: 16px 0; font-variant-numeric: tabular-nums; line-height: 1;
}
#matchFoundMessage {
margin-top: 12px; font-size: 20px; color: #fff;
text-align: center; width: 400px; margin-left: auto; margin-right: auto;
}
#closeButton {
position: absolute; right: 0; top: 0;
margin: 10px 20px 0 0; font-size: 20px; cursor: pointer;
}
`;
function buildMapsJson(): string {
const entries: string[] = [];
for (const [name, data] of Object.entries(RANKED_MAPS)) {
entries.push(`${JSON.stringify(name)}: { number: ${data.number}, image: ${JSON.stringify(data.image)} }`);
}
return `{ ${entries.join(', ')} }`;
}
function buildRegionsJson(): string {
return JSON.stringify(RANKED_REGIONS);
}
function buildRegionCheckboxes(): string {
return Object.entries(RANKED_REGIONS).map(([code, name]) => {
const inputId = code === 'as' ? 'asia' : code;
return `<div class="region-option"><input type="checkbox" id="${inputId}" value="${code}"><label for="${inputId}">${name === 'North America' ? 'NA' : name === 'Europe' ? 'EU' : 'Asia'}</label></div>`;
}).join('\n');
}
function buildQueueScript(token: string, region: string, allRegions: boolean): string {
return `
let isQueued = false;
let queueStartTime = null;
let queueInterval = null;
let queueConnection = null;
let countdownInterval = null;
let isConnecting = false;
let audioContext = null;
let notificationBuffer = null;
let currentSource = null;
let audioInitialized = false;
const selectedMaps = new Set();
const WS_URL = ${JSON.stringify(RANKED_QUEUE_WS)};
const INIT_TOKEN = ${JSON.stringify(token)};
const INIT_REGION = ${JSON.stringify(region)};
const INIT_ALL_REGIONS = ${JSON.stringify(allRegions)};
const maps = ${buildMapsJson()};
const regions = ${buildRegionsJson()};
const queueStatus = document.getElementById('queueStatus');
const statusArea = document.getElementById('statusArea');
const queueTimerDisplay = document.getElementById('queueTimerDisplay');
const regionCheckboxes = document.getElementById('regionCheckboxes');
const matchPopupOverlay = document.getElementById('matchPopupOverlay');
const countdownTimer = document.getElementById('countDownTimer');
const foundRegion = document.getElementById('foundRegion');
const queueButton = document.getElementById('queueButton');
const closeButton = document.getElementById('closeButton');
const base64String = ${JSON.stringify(QUEUE_NOTIFICATION_AUDIO)};
function saveSettings() {
const selectedRegions = Array.from(document.querySelectorAll('#regionCheckboxes input:checked')).map(el => el.value);
localStorage.setItem('queue_selectedRegions', JSON.stringify(selectedRegions));
}
function loadSettings() {
const savedRegions = localStorage.getItem('queue_selectedRegions');
if (savedRegions) {
for (const regionId of JSON.parse(savedRegions)) {
const checkbox = document.getElementById(regionId === 'as' ? 'asia' : regionId);
if (checkbox) checkbox.checked = true;
}
} else if (INIT_REGION) {
const checkbox = document.getElementById(INIT_REGION === 'as' ? 'asia' : INIT_REGION);
if (checkbox) checkbox.checked = true;
if (INIT_ALL_REGIONS) {
for (const el of document.querySelectorAll('#regionCheckboxes input')) el.checked = true;
}
}
}
const base64ToArrayBuffer = (b64) => {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
};
async function initializeAudio() {
audioContext = new AudioContext();
if (audioContext.state === 'suspended') await audioContext.resume();
const arrayBuffer = base64ToArrayBuffer(base64String);
notificationBuffer = await audioContext.decodeAudioData(arrayBuffer);
audioInitialized = true;
}
function playNotificationSound() {
if (!notificationBuffer || !audioContext) return;
try {
const source = audioContext.createBufferSource();
source.buffer = notificationBuffer;
source.connect(audioContext.destination);
source.start(0);
currentSource = source;
} catch (e) { console.error('Audio play error:', e); }
}
function stopNotificationSound() {
if (currentSource) {
try { currentSource.stop(); currentSource.disconnect(); } catch {}
currentSource = null;
}
}
function formatTime(seconds) {
const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const s = String(Math.floor(seconds % 60)).padStart(2, '0');
return h + ':' + m + ':' + s;
}
function updateCooldownTimer(ms) {
const endTime = Date.now() + ms;
function updateDisplay() {
const remaining = Math.ceil((endTime - Date.now()) / 1000);
if (remaining <= 0) {
queueStatus.textContent = 'Ready';
queueStatus.classList.remove('active');
statusArea.classList.remove('active');
queueButton.disabled = false;
return;
}
queueStatus.textContent = 'Cooldown: ' + formatTime(remaining);
queueButton.disabled = true;
setTimeout(updateDisplay, 1000);
}
updateDisplay();
}
function updateQueueTimer() {
if (queueStartTime) {
const elapsed = Math.floor((Date.now() - queueStartTime) / 1000);
queueTimerDisplay.textContent = formatTime(elapsed);
}
}
function startQueue() {
const selectedRegions = Array.from(document.querySelectorAll('#regionCheckboxes input:checked')).map(el => el.value);
if (selectedRegions.length === 0) {
queueStatus.textContent = 'Select at least one region';
queueButton.disabled = false;
isConnecting = false;
return;
}
if (selectedMaps.size === 0) {
queueStatus.textContent = 'Select at least one map';
queueButton.disabled = false;
isConnecting = false;
return;
}
const wsUrl = WS_URL + '?token=' + INIT_TOKEN + '&maps=' + Array.from(selectedMaps).join(',') + '&regions=' + selectedRegions.join(',');
try {
queueConnection = new WebSocket(wsUrl);
} catch (error) {
console.error('WebSocket creation error:', error);
queueStatus.textContent = 'Connection failed';
queueButton.disabled = false;
isConnecting = false;
return;
}
queueConnection.onerror = (error) => {
console.error('queueConnection error:', error);
queueStatus.textContent = 'Connection error';
queueStatus.classList.remove('active');
statusArea.classList.remove('active');
isQueued = false;
isConnecting = false;
queueButton.disabled = false;
};
queueConnection.onopen = () => {
isQueued = true;
isConnecting = false;
queueStartTime = Date.now();
queueButton.textContent = 'Leave Queue';
queueButton.classList.add('in-queue');
queueStatus.textContent = 'In queue';
queueStatus.classList.add('active');
statusArea.classList.add('active');
updateQueueTimer();
queueInterval = setInterval(updateQueueTimer, 1000);
queueButton.disabled = false;
};
queueConnection.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'QUEUE_STATUS':
if (data.payload.status === 'MATCHED')
matchFound(data.payload.assignment.extensions.map.trim(), data.payload.assignment.extensions.region);
break;
case 'ERROR':
if (data.payload.code === 'COOLDOWN') {
queueConnection.close();
isQueued = false;
isConnecting = false;
queueButton.disabled = false;
updateCooldownTimer(data.payload.payload.cooldown);
}
break;
case 'INTERNAL_ERROR':
queueConnection.close();
isQueued = false;
isConnecting = false;
queueButton.disabled = false;
break;
}
};
queueConnection.onclose = () => {
isQueued = false;
isConnecting = false;
clearInterval(queueInterval);
queueButton.textContent = 'Start Queue';
queueButton.classList.remove('in-queue');
queueStatus.textContent = 'Ready';
queueStatus.classList.remove('active');
statusArea.classList.remove('active');
queueTimerDisplay.textContent = '00:00:00';
queueButton.disabled = false;
};
}
function matchFound(map, region) {
playNotificationSound();
matchPopupOverlay.classList.add('active');
region = region.slice(2);
const regionName = regions[region] || region;
let foundMapName = 'unknown';
for (const [mapName, mapData] of Object.entries(maps)) {
if (mapData.number === parseInt(map, 10)) { foundMapName = mapName; break; }
}
foundRegion.textContent = regionName + ', ' + foundMapName;
const duration = 60;
const startTime = Date.now();
countdownInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const remaining = Math.max(0, duration - elapsed);
countdownTimer.textContent = formatTime(remaining);
if (remaining <= 0) {
clearInterval(countdownInterval);
matchPopupOverlay.classList.remove('active');
}
}, 1000);
isQueued = false;
isConnecting = false;
clearInterval(queueInterval);
queueButton.textContent = 'Start Queue';
queueButton.classList.remove('in-queue');
queueStatus.textContent = 'Ready';
queueStatus.classList.remove('active');
statusArea.classList.remove('active');
queueTimerDisplay.textContent = '00:00:00';
queueButton.disabled = false;
}
// Queue button
queueButton.onclick = async () => {
if (isConnecting) return;
queueButton.disabled = true;
isConnecting = true;
if (isQueued) {
queueConnection.close();
} else {
if (!audioInitialized) await initializeAudio();
startQueue();
}
};
// Close match popup
closeButton.onclick = () => {
matchPopupOverlay.classList.remove('active');
stopNotificationSound();
if (countdownInterval) clearInterval(countdownInterval);
};
// Region checkbox changes
for (const sel of regionCheckboxes.querySelectorAll('input')) {
sel.onclick = () => {
if (isQueued && queueConnection) queueConnection.close();
saveSettings();
};
}
// Init — select all maps unconditionally (ranked doesn't allow map choice)
for (const data of Object.values(maps)) selectedMaps.add(data.number);
loadSettings();
`;
}
function buildQueueHtml(token: string, region: string, allRegions: boolean): string {
return `<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>Ranked Queue</title>
<style>${QUEUE_CSS}</style>
</head>
<body>
<div class="queuer-container">
<div class="main-content">
<div class="left-section">
<div class="status-area" id="statusArea">
<span id="queueStatus">Ready</span>
</div>
<div class="timer-display" id="queueTimerDisplay">00:00:00</div>
<div class="region-controls" id="regionCheckboxes">
${buildRegionCheckboxes()}
</div>
</div>
<div class="divider"></div>
<div class="right-section">
<button type="button" class="btn btn-primary" id="queueButton">Start Queue</button>
</div>
</div>
</div>
<div class="overlay" id="matchPopupOverlay">
<div class="popup">
<h2>Match Found</h2>
<div class="popup-content">
<div id="closeButton">X</div>
<div class="region-found" id="foundRegion">Region: </div>
<div id="matchFoundMessage">open the client and rejoin the game from the ranked menu</div>
<div class="countdown-large" id="countDownTimer">00:00:60</div>
</div>
</div>
</div>
<script>${buildQueueScript(token, region, allRegions)}</script>
</body>
</html>`;
}
export function openRankedQueue(token: string, region: string, allRegions: boolean): void {
if (queueWindow && !queueWindow.isDestroyed()) {
queueWindow.focus();
return;
}
const win = new BrowserWindow({
width: 850,
height: 350,
resizable: false,
autoHideMenuBar: true,
backgroundColor: '#0d0d0d',
title: 'Ranked Queue',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
win.removeMenu();
queueWindow = win;
win.on('closed', () => { queueWindow = null; });
const html = buildQueueHtml(token, region, allRegions);
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
}
+35 -1
View File
@@ -1,4 +1,5 @@
// ── Competitive features: Hardpoint enemy counter + Rank progress tracker ──
// ── Competitive features: Hardpoint enemy counter + Rank progress tracker + Ranked queue ──
import { ipcRenderer } from 'electron';
let hpObserver: MutationObserver | null = null;
let hpCounterEl: HTMLElement | null = null;
@@ -187,6 +188,38 @@ function checkRankedMenu(): void {
}
}
// ── Ranked Queue Button ──
function injectQueueButton(): void {
const footer = document.querySelector('.footer-controls');
if (!footer || footer.querySelector('#kpc-ranked-queue-btn')) return;
const btn = document.createElement('button');
btn.id = 'kpc-ranked-queue-btn';
btn.className = 'kpc-ranked-queue-btn';
btn.innerHTML = '<span class="material-icons" style="font-size:20px;vertical-align:middle;">open_in_new</span>';
btn.title = 'Open External Queue';
btn.addEventListener('click', () => {
let token = localStorage.getItem('__FRVR_auth_access_token') || '';
token = token.replace(/"/g, '').replace(/\//g, '');
const regionEl = document.querySelector('.region-indicator');
let region = 'na';
if (regionEl) {
const text = regionEl.textContent || '';
const parts = text.split(': ');
const regionName = parts[1] || parts[0];
if (regionName.includes('Europe')) region = 'eu';
else if (regionName.includes('Asia')) region = 'as';
}
const allRegions = localStorage.getItem('s_rankedAllRegions') === 'true';
ipcRenderer.send('open-ranked-queue', token, region, allRegions);
});
const lastChild = footer.lastElementChild;
if (lastChild) footer.insertBefore(btn, lastChild);
else footer.appendChild(btn);
}
export function initRankProgress(): void {
// Poll for window.openRankedMenu — Krunker defines it async after DOM load
let attempts = 0;
@@ -207,6 +240,7 @@ export function initRankProgress(): void {
rankObserver = new MutationObserver(checkRankedMenu);
rankObserver.observe(modal, { childList: true, subtree: true });
checkRankedMenu();
injectQueueButton();
cleanupInterval = setInterval(() => {
if (!document.querySelector('.rankedMenuModal')) {