initial commit
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
2026-04-03 15:33:20 -07:00
commit aeabddcf3a
41 changed files with 16061 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
// ── Changelog Popup ──
// Shows release notes in a Shadow DOM modal when the client version changes.
import { ipcRenderer } from 'electron';
function versionLessThan(a: string, b: string): boolean {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na < nb) return true;
if (na > nb) return false;
}
return false;
}
function renderMarkdown(md: string): string {
const html = md
.replace(/### (.+)/g, '<h3>$1</h3>')
.replace(/## (.+)/g, '<h2>$1</h2>')
.replace(/# (.+)/g, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Convert list items
const lines = html.split('\n');
let inList = false;
const out: string[] = [];
for (const line of lines) {
if (line.trimStart().startsWith('- ')) {
if (!inList) { out.push('<ul>'); inList = true; }
out.push('<li>' + line.trimStart().slice(2) + '</li>');
} else {
if (inList) { out.push('</ul>'); inList = false; }
out.push(line);
}
}
if (inList) out.push('</ul>');
return out.join('\n').replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>');
}
function showChangelogPopup(version: string, body: string): void {
const host = document.createElement('div');
host.id = 'kpc-changelog-host';
const shadow = host.attachShadow({ mode: 'closed' });
const style = document.createElement('style');
style.textContent = `
.overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.75); z-index: 99998;
display: flex; justify-content: center; align-items: center;
font-family: 'Segoe UI', sans-serif; color: #e0e0e0;
}
.modal {
background: #1a1a2e; border-radius: 12px; padding: 24px;
min-width: 400px; max-width: 600px; max-height: 70vh;
display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px;
}
.header h2 { margin: 0; font-size: 1.4rem; color: #fff; }
.close-btn {
background: none; border: none; color: #888; font-size: 1.5rem;
cursor: pointer; padding: 4px 8px; border-radius: 4px;
}
.close-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
.body {
overflow-y: auto; flex: 1; line-height: 1.6;
}
.body h1 { font-size: 1.3rem; color: #fff; margin: 12px 0 6px; }
.body h2 { font-size: 1.15rem; color: #fff; margin: 10px 0 6px; }
.body h3 { font-size: 1rem; color: #ccc; margin: 8px 0 4px; }
.body ul { padding-left: 20px; margin: 6px 0; }
.body li { margin: 3px 0; }
.body a { color: #6ea8fe; }
.body strong { color: #fff; }
`;
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) host.remove();
});
const modal = document.createElement('div');
modal.className = 'modal';
const header = document.createElement('div');
header.className = 'header';
header.innerHTML = `<h2>What's New in v${version}</h2>`;
const closeBtn = document.createElement('button');
closeBtn.className = 'close-btn';
closeBtn.textContent = '\u2715';
closeBtn.addEventListener('click', () => host.remove());
header.appendChild(closeBtn);
const bodyDiv = document.createElement('div');
bodyDiv.className = 'body';
bodyDiv.innerHTML = renderMarkdown(body);
modal.appendChild(header);
modal.appendChild(bodyDiv);
overlay.appendChild(modal);
shadow.appendChild(style);
shadow.appendChild(overlay);
document.body.appendChild(host);
}
export async function checkChangelog(currentVersion: string, lastSeenVersion: string): Promise<void> {
if (lastSeenVersion && !versionLessThan(lastSeenVersion, currentVersion)) return;
// Update lastSeenVersion regardless of whether we can fetch notes
ipcRenderer.invoke('set-config', 'ui', {
...await ipcRenderer.invoke('get-config', 'ui'),
lastSeenVersion: currentVersion,
});
try {
const body = await ipcRenderer.invoke('changelog-fetch', currentVersion);
if (body) showChangelogPopup(currentVersion, body);
} catch { /* fetch failed — skip silently */ }
}
+122
View File
@@ -0,0 +1,122 @@
// ── Better Chat + Chat History ──
// Merges team/all chat with [T]/[M] prefixes and prevents Krunker from pruning old messages.
import type { SavedConsole } from './utils';
const TEAM_MODES = new Set([
'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Hide & Seek',
'Infected', 'Last Man Standing', 'Simon Says', 'Prop Hunt',
'Boss Hunt', 'Deposit', 'Stalker', 'Kill Confirmed',
'Defuse', 'Traitor', 'Blitz', 'Domination',
'Squad Deathmatch', 'Team Defender',
]);
let chatList: HTMLElement | null = null;
let observer: MutationObserver | null = null;
let historyMax = 0;
let betterChatEnabled = false;
let reInsertGuard = false;
let _con: SavedConsole | null = null;
function isChatMessage(node: Node): node is HTMLElement {
return node.nodeType === 1 && (node as HTMLElement).id?.startsWith('chatMsg_');
}
function isTeamMode(): boolean {
const modeEl = document.getElementById('gameModeLabel') || document.getElementById('subGameMode');
if (!modeEl) return false;
return TEAM_MODES.has(modeEl.textContent?.trim() || '');
}
function handleMutations(mutations: MutationRecord[]): void {
// ── Chat history: re-insert removed messages ──
if (historyMax > 0 && chatList && observer) {
const removed: HTMLElement[] = [];
for (const mut of mutations) {
if (reInsertGuard) break;
for (const node of mut.removedNodes) {
if (isChatMessage(node)) removed.push(node);
}
}
if (removed.length > 0) {
reInsertGuard = true;
observer.disconnect();
const firstLive = chatList.firstChild;
for (const node of removed) {
chatList.insertBefore(node, firstLive);
}
while (chatList.children.length > historyMax) {
chatList.removeChild(chatList.firstChild!);
}
observer.observe(chatList, { childList: true });
reInsertGuard = false;
}
}
// ── Better chat: tag new messages ──
if (betterChatEnabled) {
const teamMode = isTeamMode();
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (!isChatMessage(node)) continue;
const chatMsg = node.querySelector('.chatMsg');
if (!chatMsg) continue;
// Remove "Text & Voice Chat" system messages
if (chatMsg.textContent?.includes('Text & Voice Chat')) {
node.remove();
continue;
}
// Only tag in team modes with proper chat messages
if (!teamMode) continue;
if (!chatMsg.innerHTML.includes('\u202E:')) continue;
if (!node.dataset.tab) continue;
const isTeam = node.dataset.tab === '1';
const tag = document.createElement('div');
tag.style.cssText = 'float:left; margin-right:4px; font-weight:bold;';
tag.style.color = isTeam ? '#00FF00' : '#FF0000';
tag.textContent = isTeam ? '[T]' : '[M]';
chatMsg.insertBefore(tag, chatMsg.firstChild);
}
}
}
// Auto-scroll unless paused
if (chatList && !chatList.classList.contains('kpc-chat-paused')) {
chatList.scrollTop = chatList.scrollHeight;
}
}
function tryAttach(): boolean {
chatList = document.getElementById('chatList');
if (!chatList) return false;
observer = new MutationObserver(handleMutations);
observer.observe(chatList, { childList: true });
_con?.log('[KCC-Chat] Observer attached to #chatList');
return true;
}
export function initChat(options: { betterChat: boolean; chatHistorySize: number }, con?: SavedConsole): void {
_con = con ?? null;
betterChatEnabled = options.betterChat;
historyMax = options.chatHistorySize;
if (tryAttach()) return;
// Poll until #chatList appears
let attempts = 0;
const poll = setInterval(() => {
if (++attempts > 120 || tryAttach()) clearInterval(poll);
}, 500);
}
export function setBetterChat(enabled: boolean): void {
betterChatEnabled = enabled;
}
export function setChatHistorySize(size: number): void {
historyMax = size;
}
+68
View File
@@ -0,0 +1,68 @@
// ── Hardpoint Enemy Counter ──
// Displays enemy capture points being scored in Hardpoint mode.
let hpObserver: MutationObserver | null = null;
let hpCounterEl: HTMLElement | null = null;
let hpPointCounter: HTMLElement | null = null;
let hpEnemyOBJ = 0;
let hpTimeout: ReturnType<typeof setTimeout> | null = null;
let hpCheckInterval: ReturnType<typeof setInterval> | null = null;
function processTeamScores(): void {
const teams = document.querySelectorAll('#tScoreC1, #tScoreC2');
for (const team of teams) {
if (team.className.includes('you')) continue;
const scoreEl = team.nextElementSibling;
if (!scoreEl) continue;
const currentScore = parseInt(scoreEl.textContent || '0', 10);
if (currentScore > hpEnemyOBJ && hpPointCounter) {
hpPointCounter.textContent = String((currentScore - hpEnemyOBJ) / 10);
if (hpTimeout) clearTimeout(hpTimeout);
hpTimeout = setTimeout(() => {
if (hpPointCounter) hpPointCounter.textContent = '0';
hpTimeout = null;
}, 1600);
}
hpEnemyOBJ = currentScore;
}
}
function setupHPDisplay(): void {
const counters = document.querySelector('.topRightCounters');
if (!counters || hpCounterEl) return;
hpCounterEl = document.createElement('div');
hpCounterEl.className = 'statIcon kpc-hp-counter';
hpCounterEl.innerHTML =
'<div class="greyInner" style="display:flex">' +
'<span style="color:white;font-size:15px;margin-right:4px;">on</span>' +
'<span class="pointVal">0</span></div>';
hpPointCounter = hpCounterEl.querySelector('.pointVal');
counters.appendChild(hpCounterEl);
const teamScores = document.getElementById('teamScores');
if (teamScores) {
hpObserver = new MutationObserver(processTeamScores);
hpObserver.observe(teamScores, { childList: true, subtree: true });
}
}
export function initHPCounter(): void {
hpCheckInterval = setInterval(() => {
if (document.querySelector('.cmpTmHed')) {
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
setupHPDisplay();
}
}, 2000);
}
export function destroyHPCounter(): void {
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
if (hpObserver) { hpObserver.disconnect(); hpObserver = null; }
if (hpCounterEl) { hpCounterEl.remove(); hpCounterEl = null; }
if (hpTimeout) { clearTimeout(hpTimeout); hpTimeout = null; }
hpPointCounter = null;
hpEnemyOBJ = 0;
}
+1960
View File
File diff suppressed because it is too large Load Diff
+455
View File
@@ -0,0 +1,455 @@
// ── 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.
// Shows a live lobby-cycling search popup while scanning.
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' };
export 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'];
export const MATCHMAKER_MAP_NAMES: Record<string, string> = {
SkyTemple: 'Sky Temple', Krunk_Plaza: 'Krunk Plaza', Old_Burg: 'Old Burg',
'Soul Sanctum': 'Soul Sanctum', 'Slide Moonlight': 'Slide Moonlight', 'Eterno Sim': 'Eterno Sim',
};
// ── Animation constants ──
const MAX_FEED_ENTRIES = 4;
const MAX_ANIMATION_MS = 2000;
const BASE_TICK_MS = 80;
const MIN_TICK_MS = 20;
const POST_SCAN_PAUSE_MS = 300;
const SCAN_FLASH_MS = 800;
interface MatchmakerGame {
gameID: string;
region: string;
playerCount: number;
playerLimit: number;
map: string;
gamemode: string;
remainingTime: number;
}
interface RawLobby extends MatchmakerGame {
passesFilter: boolean;
}
export interface MatchmakerConfig {
enabled: boolean;
regions: string[];
gamemodes: string[];
maps: 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`;
}
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;
}
// ── Popup DOM (created once, reused) ──
const POPUP_ID = 'matchmakerPopupContainer';
const popupElement = document.createElement('div');
popupElement.id = POPUP_ID;
// Result-phase elements
const popupTitle = document.createElement('div');
popupTitle.id = 'matchmakerPopupTitle';
popupElement.appendChild(popupTitle);
const popupDescription = document.createElement('div');
popupDescription.id = 'matchmakerPopupDescription';
popupElement.appendChild(popupDescription);
const popupOptions = document.createElement('div');
popupOptions.id = 'matchmakerPopupOptions';
const popupConfirmBtn = document.createElement('div');
popupConfirmBtn.id = 'matchmakerConfirmButton';
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
popupConfirmBtn.textContent = 'Join';
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
const popupCancelBtn = document.createElement('div');
popupCancelBtn.id = 'matchmakerCancelButton';
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
popupCancelBtn.textContent = 'Cancel';
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
popupOptions.appendChild(popupConfirmBtn);
popupOptions.appendChild(popupCancelBtn);
popupElement.appendChild(popupOptions);
// Search-phase elements
const searchContainer = document.createElement('div');
searchContainer.id = 'matchmakerSearchContainer';
const searchStatus = document.createElement('div');
searchStatus.id = 'matchmakerSearchStatus';
searchContainer.appendChild(searchStatus);
const searchFeed = document.createElement('div');
searchFeed.id = 'matchmakerSearchFeed';
searchContainer.appendChild(searchFeed);
const searchCounter = document.createElement('div');
searchCounter.id = 'matchmakerSearchCounter';
searchContainer.appendChild(searchCounter);
const searchCancelBtn = document.createElement('div');
searchCancelBtn.id = 'matchmakerSearchCancel';
searchCancelBtn.textContent = 'Cancel';
searchCancelBtn.setAttribute('onmouseenter', 'playTick()');
searchCancelBtn.addEventListener('click', () => abortSearch());
searchContainer.appendChild(searchCancelBtn);
popupElement.appendChild(searchContainer);
// ── State ──
let popupGameID = '';
let popupCandidates: MatchmakerGame[] = [];
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 };
let searchAborted = false;
function abortSearch(): void {
searchAborted = true;
const w = window as any;
if (typeof w.playSelect === 'function') w.playSelect();
dismissPopup();
}
async function verifyAndJoin(gameID: string): Promise<void> {
try {
const resp = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
const result = await resp.json();
const liveMap = new Map<string, { players: number; limit: number }>();
for (const g of result.games) {
liveMap.set(g[0], { players: g[2], limit: g[3] });
}
const ordered = [gameID, ...popupCandidates.filter(c => c.gameID !== gameID).map(c => c.gameID)];
for (const id of ordered) {
const live = liveMap.get(id);
if (live && live.players < live.limit) {
dismissPopup();
window.location.href = `https://krunker.io/?game=${id}`;
return;
}
}
dismissPopup();
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
(window as any).openServerWindow(0);
}
} catch {
dismissPopup();
window.location.href = `https://krunker.io/?game=${gameID}`;
}
}
function dismissPopup(): void {
document.removeEventListener('keydown', handleSearchBind, true);
document.removeEventListener('keydown', handleMatchmakerBind, true);
if (popupElement.parentNode) popupElement.remove();
popupElement.classList.remove('searching');
}
function decideMatchmakerDecision(accept: boolean): void {
const w = window as any;
if (typeof w.playSelect === 'function') w.playSelect();
if (accept && popupGameID !== 'none') {
verifyAndJoin(popupGameID);
} else {
dismissPopup();
if (popupGameID === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
w.openServerWindow(0);
}
}
}
function handleSearchBind(event: KeyboardEvent): void {
if (document.pointerLockElement) return;
if (matchesKey(cancelKey, event)) {
event.preventDefault();
event.stopPropagation();
abortSearch();
}
}
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 showResultPopup(game: MatchmakerGame): void {
popupElement.classList.remove('searching');
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
popupGameID = game.gameID;
if (game.gameID === 'none') {
popupTitle.innerText = 'No Games Found...';
popupDescription.innerHTML = 'Check the server browser to see other lobbies.';
popupConfirmBtn.style.display = 'none';
} else {
popupTitle.innerText = 'Game Found!';
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
popupDescription.innerHTML = `${game.gamemode} on ${game.map} (${regionName})<br/>${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`;
popupConfirmBtn.style.display = 'block';
}
// Re-trigger slide animation
popupElement.style.animation = 'none';
void popupElement.offsetWidth;
popupElement.style.animation = '';
document.removeEventListener('keydown', handleSearchBind, true);
document.addEventListener('keydown', handleMatchmakerBind, true);
}
function showSearchPopup(): void {
searchAborted = false;
popupElement.classList.add('searching');
popupElement.style.backgroundImage = 'none';
searchStatus.textContent = 'Connecting...';
searchFeed.innerHTML = '';
searchCounter.textContent = '';
document.removeEventListener('keydown', handleMatchmakerBind, true);
document.addEventListener('keydown', handleSearchBind, true);
const uiBase = document.getElementById('uiBase');
if (uiBase) uiBase.appendChild(popupElement);
}
function createFeedEntry(lobby: RawLobby): HTMLDivElement {
const entry = document.createElement('div');
entry.className = `mm-feed-entry ${lobby.passesFilter ? 'mm-pass' : 'mm-fail'}`;
const region = document.createElement('span');
region.className = 'mm-feed-region';
region.textContent = lobby.region;
const map = document.createElement('span');
map.className = 'mm-feed-map';
map.textContent = lobby.map;
const players = document.createElement('span');
players.className = 'mm-feed-players';
players.textContent = `${lobby.playerCount}/${lobby.playerLimit}`;
entry.appendChild(region);
entry.appendChild(map);
entry.appendChild(players);
return entry;
}
async function animateLobbyScan(lobbies: RawLobby[]): Promise<void> {
if (lobbies.length === 0) return;
searchStatus.textContent = 'Scanning lobbies...';
const total = lobbies.length;
const maxEntries = Math.floor(MAX_ANIMATION_MS / BASE_TICK_MS);
const step = total > maxEntries ? total / maxEntries : 1;
const tickMs = total > maxEntries ? BASE_TICK_MS : Math.max(MIN_TICK_MS, Math.min(BASE_TICK_MS, MAX_ANIMATION_MS / total));
for (let f = 0; f < total; f += step) {
if (searchAborted) return;
const i = Math.min(Math.floor(f), total - 1);
const entry = createFeedEntry(lobbies[i]);
searchFeed.appendChild(entry);
while (searchFeed.children.length > MAX_FEED_ENTRIES) {
searchFeed.removeChild(searchFeed.firstChild!);
}
searchCounter.textContent = `Checked: ${i + 1} / ${total} lobbies`;
await new Promise(r => setTimeout(r, tickMs));
}
searchCounter.textContent = `Checked: ${total} / ${total} lobbies`;
if (!searchAborted) {
await new Promise(r => setTimeout(r, POST_SCAN_PAUSE_MS));
}
}
async function fetchAllGames(mmConfig: MatchmakerConfig): Promise<{ all: RawLobby[]; filtered: MatchmakerGame[] }> {
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
const result = await response.json();
const all: RawLobby[] = [];
const filtered: 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];
let passesFilter = true;
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) passesFilter = false;
else if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) passesFilter = false;
else if (mmConfig.maps.length > 0 && !mmConfig.maps.includes(map)) passesFilter = false;
else if (playerCount < mmConfig.minPlayers) passesFilter = false;
else if (playerCount > mmConfig.maxPlayers) passesFilter = false;
else if (remainingTime < mmConfig.minRemainingTime) passesFilter = false;
else if (playerCount === playerLimit) passesFilter = false;
else if (window.location.href.includes(gameID)) passesFilter = false;
const lobby = { gameID, region, playerCount, playerLimit, map, gamemode, remainingTime, passesFilter };
all.push(lobby);
if (passesFilter) filtered.push(lobby);
}
return { all, filtered };
}
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 (also aborts in-flight search)
searchAborted = true;
dismissPopup();
// Phase 1: Show search popup immediately
showSearchPopup();
_con?.log('[KCC-MM] Fetching game list + pings...');
// Phase 2: Fetch data
let allLobbies: RawLobby[];
let filtered: MatchmakerGame[];
let pings: Record<string, number>;
try {
const [fetchResult, pingResult] = await Promise.all([
fetchAllGames(mmConfig),
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
]);
allLobbies = fetchResult.all;
filtered = fetchResult.filtered;
pings = pingResult;
} catch {
if (!searchAborted) {
searchStatus.textContent = 'Failed to fetch lobbies';
await new Promise(r => setTimeout(r, 2000));
dismissPopup();
}
return;
}
if (searchAborted) return;
_con?.log('[KCC-MM]', filtered.length, '/', allLobbies.length, 'games passed filters');
// Sort immediately — result is ready
if (filtered.length > 0) sortByPingThenPlayers(filtered, pings);
popupCandidates = filtered;
// Fire animation in background (non-blocking eye candy)
animateLobbyScan(allLobbies);
// Brief visual flash of the feed before showing result
await new Promise(r => setTimeout(r, SCAN_FLASH_MS));
if (searchAborted) return;
// Phase 3: Show result
if (filtered.length > 0) {
// Pick randomly from the top tier of comparable matches for variety
const top = filtered[0];
const topPing = pings[top.region] ?? 999;
const pool = filtered.filter(g => {
const gPing = pings[g.region] ?? 999;
return Math.abs(gPing - topPing) <= 20
&& top.playerCount - g.playerCount <= 2;
});
const best = pool[Math.floor(Math.random() * pool.length)];
_con?.log('[KCC-MM] Best match:', best.gameID, best.region, best.map, `(${pings[best.region] ?? '?'}ms, pool: ${pool.length})`);
if (mmConfig.autoJoin) {
// Brief "Lobby Found!" flash before joining
const regionName = MATCHMAKER_REGION_NAMES[best.region] ?? best.region;
searchStatus.textContent = 'Lobby Found!';
searchFeed.innerHTML = '';
const found = document.createElement('div');
found.className = 'mm-feed-entry mm-pass';
found.style.cssText = 'font-size:1.1em;justify-content:center;';
found.innerHTML =
`<span class="mm-feed-region">${best.region}</span>` +
`<span class="mm-feed-map">${best.map}</span>` +
`<span class="mm-feed-players">${best.playerCount}/${best.playerLimit}</span>`;
searchFeed.appendChild(found);
searchCounter.textContent = `${best.gamemode} \u00B7 ${regionName} \u00B7 ${pings[best.region] ?? '?'}ms`;
await new Promise(r => setTimeout(r, 1200));
await verifyAndJoin(best.gameID);
return;
}
showResultPopup(best);
} else {
_con?.log('[KCC-MM] No matching games found');
if (mmConfig.autoJoin) {
dismissPopup();
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
(window as any).openServerWindow(0);
}
return;
}
showResultPopup({
gameID: 'none',
region: 'none',
playerCount: 0,
playerLimit: 0,
map: MAP_ICON_INDICES[0],
gamemode: MATCHMAKER_GAMEMODES[0],
remainingTime: 0,
});
}
}
+361
View File
@@ -0,0 +1,361 @@
import type { SavedConsole } from './utils';
// ── Config ──
interface TranslatorConfig {
enabled: boolean;
targetLanguage: string;
showLanguageTag: boolean;
}
const DEFAULTS: TranslatorConfig = {
enabled: true,
targetLanguage: 'en',
showLanguageTag: true,
};
// ── Module state ──
let _con: SavedConsole;
let cfg: TranslatorConfig = { ...DEFAULTS };
let chatObserver: MutationObserver | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
// ── Translation cache (sessionStorage, 10-min expiry) ──
const CACHE_KEY_PREFIX = 'kccTL_';
const CACHE_EXPIRY_MS = 10 * 60 * 1000;
interface CacheEntry {
t: string; // translation
l: string; // source language
ts: number; // timestamp
}
function cacheGet(text: string): CacheEntry | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY_PREFIX + text.toLowerCase().trim());
if (!raw) return null;
const entry: CacheEntry = JSON.parse(raw);
if (Date.now() - entry.ts > CACHE_EXPIRY_MS) return null;
return entry;
} catch { return null; }
}
function cacheSet(text: string, translation: string, srcLang: string): void {
try {
const entry: CacheEntry = { t: translation, l: srcLang, ts: Date.now() };
sessionStorage.setItem(CACHE_KEY_PREFIX + text.toLowerCase().trim(), JSON.stringify(entry));
} catch { /* sessionStorage full */ }
}
// ── Skip terms (gaming/chat slang — never sent for translation) ──
const SKIP_TERMS = new Set([
// Greetings & basics
'hi', 'hey', 'hello', 'yo', 'sup', 'bye', 'cya', 'gn', 'gm',
'yes', 'no', 'yep', 'yea', 'yeah', 'nah', 'nope', 'ok', 'okay', 'kk',
// Chat abbreviations
'lol', 'lmao', 'lmfao', 'rofl', 'omg', 'omfg', 'wtf', 'wth',
'bruh', 'bro', 'dude', 'man', 'brb', 'afk', 'gtg', 'g2g',
'smh', 'tbh', 'imo', 'imho', 'ngl', 'fr', 'frfr', 'fax',
'idk', 'idc', 'idgaf', 'nvm', 'stfu', 'pls', 'plz',
'thx', 'ty', 'tysm', 'np', 'yw', 'mb', 'sry', 'sorry',
'bet', 'cap', 'nocap', 'sus', 'mid', 'based', 'cringe', 'ratio',
'rip', 'oof', 'uwu', 'owo', 'xd', 'xdd', 'xddd', 'lel', 'kek',
'damn', 'dang', 'boi', 'fam', 'goat', 'goated',
'lit', 'vibe', 'vibes', 'lowkey', 'highkey', 'deadass',
'nice', 'cool', 'sick', 'fire', 'trash', 'ass', 'toxic',
'wow', 'whoa', 'wha', 'huh', 'wat', 'wut', 'hmm',
// Gaming general
'gg', 'ggwp', 'ggez', 'wp', 'ez', 'gl', 'hf', 'glhf',
'nt', 'ns', 'gj', 'mvp', 'clutch', 'ace', 'carry',
'noob', 'newb', 'n00b', 'bot', 'tryhard', 'sweat', 'sweaty',
'hack', 'hacks', 'hacker', 'hax', 'cheater', 'cheats',
'lag', 'laggy', 'ping', 'fps', 'dc', 'disconnect',
'nerf', 'buff', 'op', 'broken', 'meta', 'spam', 'camp', 'camper',
'aim', 'aimbot', 'wh', 'wallhack', 'esp',
'rush', 'push', 'rotate', 'flank', 'peek', 'hold',
'one', 'low', 'dead', 'down', 'res', 'revive',
'w', 'l', 'dub', 'win', 'loss', 'f', 'ggs',
// Krunker-specific
'kr', 'ak', 'smg', 'sniper', 'shotty', 'rev', 'semi',
'crossy', 'famas', 'rpg', 'lmg', 'deagle', 'comp',
'pub', 'pubs', 'ranked', 'nuke', 'nuked', 'nuking',
'kpd', 'bhop', 'bhopping', 'slidehopping', 'slidehop',
'krunker', 'krunky', 'yendis', 'krunkitis',
'contra', 'relic', 'unob', 'unobtainable', 'spin',
'market', 'trade', 'gift', 'drop', 'drops', 'skin', 'skins',
'clan', 'verified', 'lvl', 'level',
'trig', 'trigger', 'runner', 'det', 'detective',
'vince', 'bowman', 'spray', 'agent', 'rocketeer',
'streamer', 'ttv',
// Emoticons
':)', ':(', ':d', ':p', ':o', '<3',
]);
// ── False-positive source languages ──
const FALSE_POSITIVE_LANGS = new Set([
'so', 'cy', 'ht', 'hmn', 'ceb', 'haw', 'la', 'mg', 'mi',
'ny', 'sm', 'st', 'su', 'sw', 'tl', 'yo', 'zu', 'sn',
'ig', 'rw', 'co', 'fy', 'gd', 'lb', 'mt', 'eo',
]);
// ── Auto-suppression (repeated short phrases) ──
const suppressionCounts = new Map<string, number>();
const SUPPRESS_THRESHOLD = 3;
const MIN_LATIN_WORDS = 3;
const SHORT_TEXT_THRESHOLD = 15;
// ── Concurrency control ──
let activeRequests = 0;
const MAX_CONCURRENT = 3;
const pendingQueue: Array<() => void> = [];
function enqueue(fn: () => Promise<void>): void {
if (activeRequests < MAX_CONCURRENT) {
activeRequests++;
fn().finally(() => {
activeRequests--;
if (pendingQueue.length > 0) pendingQueue.shift()!();
});
} else {
pendingQueue.push(() => enqueue(fn));
}
}
// ── System message patterns to skip ──
const SYSTEM_PATTERNS = [
'joined the game', 'left the game', 'has been kicked', 'has been banned',
'vote to kick', 'press f1', 'connecting', 'connected', 'was arrested',
'started a vote', 'was kicked', 'was banned',
];
// ── Pre-translation filtering ──
function isLatinOnly(text: string): boolean {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text);
}
function shouldTranslate(text: string): boolean {
const cleaned = text.trim();
if (cleaned.length < 2) return false;
// Tokenize for skip-term checking
const words = cleaned.replace(/[^a-zA-Z0-9\s]/g, '').toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return false;
if (words.every(w => SKIP_TERMS.has(w))) return false;
// Auto-suppressed phrases
const key = cleaned.toLowerCase();
if ((suppressionCounts.get(key) ?? 0) >= SUPPRESS_THRESHOLD) return false;
// Non-Latin characters = almost certainly needs translation
if (!isLatinOnly(cleaned)) return true;
// Latin-only: require minimum word count (short English slang triggers false positives)
if (words.length < MIN_LATIN_WORDS) {
// Allow if accented characters suggest non-English
if (!/[À-ÿ]/.test(cleaned)) return false;
}
return true;
}
// ── Chat text extraction ──
interface ChatExtraction {
message: string;
username: string; // "Username:" prefix or empty
}
function extractChatText(node: HTMLElement): ChatExtraction | null {
const text = node.textContent?.trim();
if (!text || text.length < 2) return null;
// Skip nodes with images (kill feed has weapon/skull icons)
if (node.querySelector('img')) return null;
// Skip commands
if (text.startsWith('/')) return null;
// Skip system messages
const lower = text.toLowerCase();
if (SYSTEM_PATTERNS.some(p => lower.includes(p))) return null;
// Extract message content after "Username: " prefix
const colonIdx = text.indexOf(':');
if (colonIdx > 0 && colonIdx < 25) {
const username = text.substring(0, colonIdx + 1);
const msg = text.substring(colonIdx + 1).trim();
return msg.length >= 2 ? { message: msg, username } : null;
}
return { message: text, username: '' };
}
// ── Google Translate API ──
async function translateText(text: string): Promise<{ translation: string; srcLang: string } | null> {
// Check cache
const cached = cacheGet(text);
if (cached) return { translation: cached.t, srcLang: cached.l };
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl='
+ cfg.targetLanguage + '&dt=t&q=' + encodeURIComponent(text);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
_con.warn('[KCC-TL] HTTP', response.status);
return null;
}
const data = await response.json();
if (!data?.[0]?.[0]) return null;
const translation = (data[0] as any[]).map((item: any) => item[0]).join('');
const srcLang: string = data[2] || 'unknown';
// Already in target language
if (srcLang === cfg.targetLanguage) return null;
// Identical translation (strip punctuation/whitespace for robust comparison)
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
if (norm(translation) === norm(text)) return null;
// Post-filter: false-positive languages on short text
if (text.length < SHORT_TEXT_THRESHOLD && FALSE_POSITIVE_LANGS.has(srcLang)) {
const key = text.toLowerCase().trim();
suppressionCounts.set(key, (suppressionCounts.get(key) ?? 0) + 1);
return null;
}
// Track short phrases for auto-suppression learning
const wordCount = text.trim().split(/\s+/).length;
if (wordCount <= 2) {
const key = text.toLowerCase().trim();
const count = (suppressionCounts.get(key) ?? 0) + 1;
suppressionCounts.set(key, count);
if (count >= SUPPRESS_THRESHOLD) return null;
}
cacheSet(text, translation, srcLang);
return { translation, srcLang };
} catch (err: any) {
if (err.name !== 'AbortError') _con.warn('[KCC-TL] Error:', err.message);
return null;
}
}
// ── DOM manipulation ──
function appendTranslation(chatNode: HTMLElement, username: string, translation: string, srcLang: string): void {
const div = document.createElement('div');
div.className = 'kcc-translation';
const langTag = (cfg.showLanguageTag && srcLang !== 'unknown') ? ' [' + srcLang.toUpperCase() + ']' : '';
div.textContent = '\u{1F310} ' + (username ? username + ' ' : '') + translation + langTag;
chatNode.appendChild(div);
}
// ── Message processing ──
function processMessage(node: HTMLElement): void {
if (node.hasAttribute('data-kpc-translated')) return;
node.setAttribute('data-kpc-translated', '1');
const extracted = extractChatText(node);
if (!extracted) return;
if (!shouldTranslate(extracted.message)) return;
const { message, username } = extracted;
enqueue(async () => {
const result = await translateText(message);
if (result) appendTranslation(node, username, result.translation, result.srcLang);
});
}
// ── Observer lifecycle ──
function startObserver(): void {
if (chatObserver) return;
let attempts = 0;
pollTimer = setInterval(() => {
attempts++;
const chatList = document.getElementById('chatList');
if (!chatList) {
if (attempts > 60) {
clearInterval(pollTimer!);
pollTimer = null;
_con.warn('[KCC-TL] #chatList not found after 30s, giving up');
}
return;
}
clearInterval(pollTimer!);
pollTimer = null;
chatObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) processMessage(node as HTMLElement);
}
}
});
chatObserver.observe(chatList, { childList: true });
_con.log('[KCC-TL] Chat observer active');
}, 500);
}
function stopObserver(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (chatObserver) {
chatObserver.disconnect();
chatObserver = null;
}
}
// ── Public API ──
export function initTranslator(savedConsole: SavedConsole, initCfg: TranslatorConfig): void {
_con = savedConsole;
cfg = {
enabled: initCfg.enabled ?? DEFAULTS.enabled,
targetLanguage: initCfg.targetLanguage ?? DEFAULTS.targetLanguage,
showLanguageTag: initCfg.showLanguageTag ?? DEFAULTS.showLanguageTag,
};
if (!cfg.enabled) {
_con.log('[KCC-TL] Translator disabled');
return;
}
_con.log('[KCC-TL] Initializing (target: ' + cfg.targetLanguage + ')');
startObserver();
}
export function updateTranslatorConfig(update: Partial<TranslatorConfig>): void {
if (update.enabled !== undefined) {
cfg.enabled = update.enabled;
if (update.enabled && !chatObserver) startObserver();
if (!update.enabled) stopObserver();
}
if (update.targetLanguage !== undefined) cfg.targetLanguage = update.targetLanguage;
if (update.showLanguageTag !== undefined) cfg.showLanguageTag = update.showLanguageTag;
}
+258
View File
@@ -0,0 +1,258 @@
import { ipcRenderer, webFrame } from 'electron';
// ── Types ──
export interface ScriptMetadata {
name: string;
author: string;
version: string;
desc: string;
src: string;
license: string;
runAt: 'document-start' | 'document-end';
priority: number;
}
export interface UserscriptSetting {
title: string;
type: 'bool' | 'num' | 'sel' | 'color' | 'keybind';
value: unknown;
desc?: string;
min?: number;
max?: number;
step?: number;
opts?: (string | number)[];
changed?: (value: unknown) => void;
}
export interface UserscriptInstance {
filename: string;
content: string;
meta: ScriptMetadata;
enabled: boolean;
executed: boolean;
unload: (() => void) | null;
settings: Record<string, UserscriptSetting> | null;
}
// ── State ──
const instances: UserscriptInstance[] = [];
const cssHandles = new Map<string, string>(); // identifier -> webFrame CSS key
// ── Metadata parser ──
export function parseMetadata(code: string): ScriptMetadata {
const meta: ScriptMetadata = {
name: '',
author: '',
version: '',
desc: '',
src: '',
license: '',
runAt: 'document-end',
priority: 0,
};
const startMatch = code.match(/\/\/\s*==UserScript==/);
const endMatch = code.match(/\/\/\s*==\/UserScript==/);
if (!startMatch || !endMatch) return meta;
const block = code.substring(
startMatch.index! + startMatch[0].length,
endMatch.index!,
);
for (const line of block.split('\n')) {
const m = line.match(/\/\/\s*@(\S+)\s+(.*)/);
if (!m) continue;
const [, tag, val] = m;
const v = val.trim();
switch (tag) {
case 'name': meta.name = v; break;
case 'author': meta.author = v; break;
case 'version': meta.version = v; break;
case 'desc':
case 'description': meta.desc = v; break;
case 'src': meta.src = v; break;
case 'license': meta.license = v; break;
case 'run-at':
if (v === 'document-start') meta.runAt = 'document-start';
else meta.runAt = 'document-end';
break;
case 'priority':
meta.priority = parseInt(v, 10) || 0;
break;
}
}
return meta;
}
// ── CSS injection via webFrame ──
function toggleCSS(css: string, identifier: string, value: boolean): void {
const existing = cssHandles.get(identifier);
if (value) {
if (existing) return; // already inserted
const key = webFrame.insertCSS(css);
cssHandles.set(identifier, key);
} else {
if (!existing) return;
webFrame.removeInsertedCSS(existing);
cssHandles.delete(identifier);
}
}
// ── Script execution ──
function executeScript(
instance: UserscriptInstance,
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): void {
if (instance.executed) return;
const context: Record<string, unknown> = {
_console,
_css(css: string, identifier: string, value: boolean) {
toggleCSS(css, instance.filename + ':' + identifier, value);
},
unload: null as (() => void) | null,
settings: null as Record<string, UserscriptSetting> | null,
};
try {
const fn = new Function(instance.content);
const result = fn.apply(context);
// Script returned `this` — capture settings and unload
if (result === context) {
instance.unload = (typeof context.unload === 'function') ? context.unload as () => void : null;
instance.settings = context.settings as Record<string, UserscriptSetting> | null;
} else {
instance.unload = null;
instance.settings = null;
}
instance.executed = true;
_console.log('[KCC] Userscript executed:', instance.meta.name || instance.filename);
} catch (err) {
_console.error('[KCC] Userscript error in', instance.filename, ':', err);
}
}
// ── Apply saved preferences ──
async function applyPreferences(instance: UserscriptInstance): Promise<void> {
if (!instance.settings) return;
const saved = await ipcRenderer.invoke('userscripts-load-prefs', instance.filename);
for (const key of Object.keys(instance.settings)) {
if (key in saved) {
const setting = instance.settings[key];
setting.value = saved[key];
if (typeof setting.changed === 'function') {
try { setting.changed(setting.value); } catch { /* ignore callback errors */ }
}
}
}
}
// ── Public API ──
export function getInstances(): UserscriptInstance[] {
return instances;
}
export async function initUserscripts(
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): Promise<void> {
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
if (!scripts || scripts.length === 0) {
_console.log('[KCC] No userscripts found');
return;
}
// Build instances
for (const script of scripts) {
const meta = parseMetadata(script.content);
instances.push({
filename: script.filename,
content: script.content,
meta,
enabled: tracker[script.filename] === true,
executed: false,
unload: null,
settings: null,
});
}
// Sort by priority descending
instances.sort((a, b) => b.meta.priority - a.meta.priority);
// Execute document-start scripts
for (const inst of instances) {
if (inst.enabled && inst.meta.runAt === 'document-start') {
executeScript(inst, _console);
await applyPreferences(inst);
}
}
// Execute document-end scripts
const runDocEnd = () => {
for (const inst of instances) {
if (inst.enabled && inst.meta.runAt === 'document-end' && !inst.executed) {
executeScript(inst, _console);
applyPreferences(inst);
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runDocEnd, { once: true });
} else {
runDocEnd();
}
_console.log('[KCC] Userscripts initialized:', instances.length, 'scripts loaded');
}
export function setScriptEnabled(
filename: string,
enabled: boolean,
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): { needsReload: boolean } {
const inst = instances.find(i => i.filename === filename);
if (!inst) return { needsReload: false };
inst.enabled = enabled;
// Update tracker
const tracker: Record<string, boolean> = {};
for (const i of instances) tracker[i.filename] = i.enabled;
ipcRenderer.invoke('userscripts-set-tracker', tracker);
if (!enabled) {
if (inst.unload && inst.executed) {
try {
inst.unload();
_console.log('[KCC] Userscript unloaded:', inst.meta.name || inst.filename);
} catch (err) {
_console.error('[KCC] Userscript unload error:', err);
}
inst.executed = false;
inst.unload = null;
inst.settings = null;
return { needsReload: false };
}
// No unload function — need page reload to fully disable
return { needsReload: inst.executed };
} else {
// Enabling
if (!inst.executed) {
executeScript(inst, _console);
applyPreferences(inst);
return { needsReload: false };
}
return { needsReload: false };
}
}
+116
View File
@@ -0,0 +1,116 @@
// ── Shared preload utilities ──
// Common types, helpers, and constants used across preload modules.
// ── Shared interfaces ──
export interface SavedConsole {
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
// ── HTML escaping ──
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
};
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]);
}
// ── Chat message injection ──
// Creates messages in #chatHolder inside a persistent #kpcMessageHolder div.
// timeout=0 means the message is persistent (not auto-removed).
export function genChatMsg(text: string, timeout = 2.25): HTMLElement | null {
const chatHolder = document.getElementById('chatHolder');
if (!chatHolder) return null;
if (!document.getElementById('kpcMessageHolder')) {
chatHolder.insertAdjacentHTML('afterbegin', '<div id="kpcMessageHolder"></div>');
}
const holder = document.getElementById('kpcMessageHolder')!;
holder.insertAdjacentHTML('beforeend',
'<div class="chatHolder_kpc"><div class="chatItem_kpc"><span class="chatMsg_kpc">' +
escapeHtml(text) + '</span></div></div>');
const elem = holder.lastElementChild as HTMLElement;
if (timeout !== 0) {
setTimeout(() => { elem.remove(); }, timeout * 1000);
}
return elem;
}
// ── Filename sanitisation ──
export function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
// ── Shared CSS constants ──
export const DEATH_ANIM_BLOCK_ID = 'kpc-animationBlock';
export const DEATH_ANIM_BLOCK_CSS =
'.death-ui-bottom, .death-ui-bottom-empty { animation: none !important; transition: none !important; }';
/** Inject or remove the death screen animation block style element. */
export function setDeathAnimBlock(enabled: boolean): void {
let el = document.getElementById(DEATH_ANIM_BLOCK_ID);
if (enabled) {
if (!el) {
el = document.createElement('style');
el.id = DEATH_ANIM_BLOCK_ID;
el.textContent = DEATH_ANIM_BLOCK_CSS;
document.head.appendChild(el);
}
} else if (el) {
el.remove();
}
}
// ── Cleaner Menu ──
// Hides clutter from the main menu for a streamlined look.
const CLEANER_MENU_ID = 'kpc-cleanerMenu';
const CLEANER_MENU_CSS = `
*::-webkit-scrollbar { display: none !important; }
.settingsBtn[style*="width:auto;background-color:#994cd1"] { display: none !important; }
.setSugBox2 { display: none !important; }
.advancedSwitch { display: none !important; }
.menuSocialB { display: none !important; }
.serverHostOpH { display: none !important; }
.signup-rewards-container { display: none !important; }
#tlInfHold { display: none !important; }
#gameNameHolder { display: none !important; }
#termsInfo { display: none !important; }
#bubbleContainer { display: none !important; }
#instructions:only-child { display: none !important; }
#mapInfoHld { display: none !important; }
#krDiscountAd { display: none !important; }
#classPreviewCanvas { display: none !important; }
#menuClassSubtext { display: none !important; }
#settingsPreset { display: none !important; }
#menuClassName { display: none !important; }
#menuBtnQuickMatch { display: none !important; }
#menuClassIcn { display: none !important; }
#streamContainerNew { display: none !important; }
#editorBtnM { display: none !important; }
.verticalSeparator { visibility: hidden !important; }
#mLevelCont { background-color: transparent; }
#uiBase.onMenu #spectButton { top: 94% !important; }
.headerBarL, .headerBar, .menuBtnHL { background-color: transparent; }
.headerBarR { right: -23px !important; }
`;
export function setCleanerMenu(enabled: boolean): void {
let el = document.getElementById(CLEANER_MENU_ID);
if (enabled) {
if (!el) {
el = document.createElement('style');
el.id = CLEANER_MENU_ID;
el.textContent = CLEANER_MENU_CSS;
document.head.appendChild(el);
}
} else if (el) {
el.remove();
}
}