// ==UserScript==
// @name Enhanced Keno Tracker
// @namespace http://zachwozn.com/
// @version 2.5
// @description Track Keno numbers with multi-round support. Allows adding/overwriting on import. Pick counts can now be sorted by frequency or number.
// @author zachwozn
// @match https://www.torn.com/page.php?sid=keno
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Constants for localStorage keys
const LS_KEY_POSITION = 'kenoTrackerPosition';
const LS_KEY_PICK_COUNTS = 'kenoNumberPickCounts';
const LS_KEY_SORT_ORDER = 'kenoCountSortOrder';
// --- UI Creation Function ---
function createUI() {
const container = document.createElement('div');
container.id = 'kenoUI';
Object.assign(container.style, {
position: 'fixed',
top: `${getPosition().top}px`,
left: `${getPosition().left}px`,
backgroundColor: '#1e293b',
color: '#f9fafb',
padding: '20px',
borderRadius: '10px',
zIndex: '9999',
fontFamily: 'Arial, sans-serif',
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.2)',
maxWidth: '350px',
overflowY: 'auto',
textAlign: 'center',
fontSize: '14px',
resize: 'both',
overflow: 'auto',
minWidth: '250px',
minHeight: '200px',
cursor: 'grab'
});
const style = document.createElement('style');
style.textContent = `
#kenoUI *, #kenoUI *:focus, #kenoUI *:active, #kenoUI *:hover { outline: none !important; box-shadow: none !important; -webkit-tap-highlight-color: transparent !important; }
#kenoUI h3, #kenoUI h4 { border: none !important; background: none !important; padding: 0 !important; margin: 0 0 5px 0 !important; }
#kenoUI #winningNumbers, #kenoUI #lostNumbers { margin-top: 0 !important; display: flex !important; flex-wrap: wrap !important; gap: 5px !important; justify-content: center; }
#kenoUI #numberCountDisplay { margin-top: 0 !important; }
#kenoUI #winningNumbers li, #kenoUI #lostNumbers li { display: inline-block !important; margin: 0 !important; padding: 2px 5px; border-radius: 3px; background-color: #2d3748; }
#kenoUI #winningNumbersSection, #kenoUI #lostNumbersSection { margin-bottom: 15px !important; }
/* --- Custom Scrollbar Styling (New Addition) --- */
/* For Firefox */
#kenoUI, #winningNumbers, #lostNumbers, #numberCountDisplay {
scrollbar-width: thin;
scrollbar-color: #718096 #1e293b;
}
/* For WebKit Browsers (Chrome, Safari, Edge, etc.) */
#kenoUI::-webkit-scrollbar {
width: 8px;
height: 8px;
}
#kenoUI::-webkit-scrollbar-track {
background: transparent;
}
#kenoUI::-webkit-scrollbar-thumb {
background-color: #4a5568;
border-radius: 10px;
border: 2px solid #1e293b;
}
#kenoUI::-webkit-scrollbar-thumb:hover {
background-color: #718096;
}
`;
document.head.appendChild(style);
const title = document.createElement('h3');
title.textContent = 'Keno Tracker';
Object.assign(title.style, { fontSize: '18px', fontWeight: 'bold', cursor: 'grab' });
container.appendChild(title);
const winningSection = document.createElement('div');
winningSection.id = 'winningNumbersSection';
const winningTitle = document.createElement('h4');
winningTitle.textContent = 'System Winning Numbers:';
winningSection.appendChild(winningTitle);
const winningList = document.createElement('ul');
winningList.id = 'winningNumbers';
Object.assign(winningList.style, { listStyleType: 'none', padding: '5px', maxHeight: '100px', overflowY: 'auto', border: '1px solid #4a5568', borderRadius: '5px' });
winningSection.appendChild(winningList);
container.appendChild(winningSection);
const lostSection = document.createElement('div');
lostSection.id = 'lostNumbersSection';
const lostTitle = document.createElement('h4');
lostTitle.textContent = 'System Lost Numbers:';
lostSection.appendChild(lostTitle);
const lostList = document.createElement('ul');
lostList.id = 'lostNumbers';
Object.assign(lostList.style, { listStyleType: 'none', padding: '5px', maxHeight: '100px', overflowY: 'auto', border: '1px solid #4a5568', borderRadius: '5px' });
lostSection.appendChild(lostList);
container.appendChild(lostSection);
const countSection = document.createElement('div');
countSection.id = 'countSection';
const countHeader = document.createElement('div');
Object.assign(countHeader.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '5px' });
const countTitle = document.createElement('h4');
countTitle.textContent = 'System Pick Counts:';
countHeader.appendChild(countTitle);
const sortButton = document.createElement('button');
sortButton.id = 'countSortToggle';
Object.assign(sortButton.style, { fontSize: '11px', padding: '2px 6px', cursor: 'pointer', backgroundColor: '#4a5568', color: 'white', border: '1px solid #718096', borderRadius: '4px' });
sortButton.addEventListener('click', () => {
countSortOrder = (countSortOrder === 'byPick') ? 'byNumber' : 'byPick';
localStorage.setItem(LS_KEY_SORT_ORDER, countSortOrder);
updateDisplay();
});
countHeader.appendChild(sortButton);
countSection.appendChild(countHeader);
const numberCount = document.createElement('div');
numberCount.id = 'numberCountDisplay';
Object.assign(numberCount.style, { maxHeight: '150px', overflowY: 'auto', padding: '10px', backgroundColor: '#2d3748', borderRadius: '5px', marginBottom: '20px', textAlign: 'left', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(60px, 1fr))', gap: '5px' });
countSection.appendChild(numberCount);
container.appendChild(countSection);
const clearButton = document.createElement('button');
clearButton.textContent = 'Clear All Data';
Object.assign(clearButton.style, { backgroundColor: '#ef4444', color: '#fff', padding: '10px 15px', border: 'none', borderRadius: '5px', cursor: 'pointer', transition: 'background-color 0.2s ease', width: '100%', marginBottom: '10px' });
clearButton.onmouseover = () => clearButton.style.backgroundColor = '#dc2626';
clearButton.onmouseout = () => clearButton.style.backgroundColor = '#ef4444';
clearButton.addEventListener('click', clearData);
container.appendChild(clearButton);
const ioContainer = document.createElement('div');
Object.assign(ioContainer.style, { display: 'flex', gap: '10px', justifyContent: 'center' });
const importButton = document.createElement('button');
importButton.textContent = 'Import Counts';
Object.assign(importButton.style, { backgroundColor: '#2563eb', color: '#fff', padding: '10px 15px', border: 'none', borderRadius: '5px', cursor: 'pointer', transition: 'background-color 0.2s ease', flex: '1' });
importButton.onmouseover = () => importButton.style.backgroundColor = '#1d4ed8';
importButton.onmouseout = () => importButton.style.backgroundColor = '#2563eb';
importButton.addEventListener('click', importPickCounts);
const exportButton = document.createElement('button');
exportButton.textContent = 'Export Counts';
Object.assign(exportButton.style, { backgroundColor: '#16a34a', color: '#fff', padding: '10px 15px', border: 'none', borderRadius: '5px', cursor: 'pointer', transition: 'background-color 0.2s ease', flex: '1' });
exportButton.onmouseover = () => exportButton.style.backgroundColor = '#15803d';
exportButton.onmouseout = () => exportButton.style.backgroundColor = '#16a34a';
exportButton.addEventListener('click', exportPickCounts);
ioContainer.appendChild(importButton);
ioContainer.appendChild(exportButton);
container.appendChild(ioContainer);
document.body.appendChild(container);
setupDrag(container);
}
function setupDrag(element) {
let isDragging = false, offsetX, offsetY;
element.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.closest('button, ul, #numberCountDisplay')) return;
const isDraggableArea = e.target === element || e.target.closest('h3, h4');
if (isDraggableArea) {
isDragging = true;
const rect = element.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
element.style.cursor = 'grabbing';
}
});
document.addEventListener('mousemove', (e) => { if (isDragging) { e.preventDefault(); element.style.left = `${e.clientX - offsetX}px`; element.style.top = `${e.clientY - offsetY}px`; } });
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; element.style.cursor = 'grab'; savePosition(element.style.left, element.style.top); } });
}
let numberPickCounts;
let roundsToPlay = 0;
let roundsProcessedInSession = 0;
let countSortOrder;
// --- Helper Functions ---
function savePosition(left, top) { localStorage.setItem(LS_KEY_POSITION, JSON.stringify({ top: parseInt(top), left: parseInt(left) })); }
function getPosition() { return JSON.parse(localStorage.getItem(LS_KEY_POSITION)) || { top: 20, left: 20 }; }
function clearData() {
if (confirm('Are you sure you want to clear ALL tracker data? This action cannot be undone.')) {
localStorage.removeItem(LS_KEY_PICK_COUNTS);
numberPickCounts = {};
roundsToPlay = 0;
roundsProcessedInSession = 0;
updateDisplay();
alert('All Keno tracker data has been cleared.');
}
}
function exportPickCounts() {
const data = localStorage.getItem(LS_KEY_PICK_COUNTS);
if (!data || data === '{}') {
alert('No pick count data to export.');
return;
}
navigator.clipboard.writeText(data).then(() => {
alert('System pick counts have been copied to your clipboard.');
}).catch(err => {
console.error('Failed to copy data automatically: ', err);
prompt('Could not automatically copy. Please copy the data manually from here:', data);
});
}
function importPickCounts() {
const rawData = prompt('Paste your exported Keno pick count data here:');
if (!rawData) { return; }
let parsedData;
try {
parsedData = JSON.parse(rawData);
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) throw new Error('Data is not a valid JSON object.');
} catch (error) {
alert('Import failed. The data provided was not in a valid format.');
return;
}
const importMethod = prompt("How would you like to import?\n\nType 'overwrite' to replace all existing data.\nType 'add' to sum the imported counts with your existing data.", "add");
if (!importMethod) { return; }
const method = importMethod.toLowerCase();
if (method === 'overwrite') {
if (confirm('This will completely REPLACE your current pick counts. Are you sure?')) {
localStorage.setItem(LS_KEY_PICK_COUNTS, JSON.stringify(parsedData));
numberPickCounts = parsedData;
updateDisplay();
alert('Pick counts overwritten successfully!');
}
} else if (method === 'add') {
if (confirm('This will ADD the imported counts to your current data, summing the totals. Are you sure?')) {
let currentCounts = JSON.parse(localStorage.getItem(LS_KEY_PICK_COUNTS)) || {};
for (const number in parsedData) {
if (Object.hasOwnProperty.call(parsedData, number)) {
currentCounts[number] = (parseInt(currentCounts[number]) || 0) + (parseInt(parsedData[number]) || 0);
}
}
localStorage.setItem(LS_KEY_PICK_COUNTS, JSON.stringify(currentCounts));
numberPickCounts = currentCounts;
updateDisplay();
alert('Pick counts added successfully!');
}
} else {
alert('Invalid import method. Action cancelled.');
}
}
function updateDisplay() {
const winningList = document.getElementById('winningNumbers');
const lostList = document.getElementById('lostNumbers');
const numberCountDisplay = document.getElementById('numberCountDisplay');
const sortButton = document.getElementById('countSortToggle');
if (!winningList || !lostList || !numberCountDisplay) return;
winningList.innerHTML = ''; lostList.innerHTML = ''; numberCountDisplay.innerHTML = '';
const currentWinners = Array.from(document.querySelectorAll('#kenoGame #boardContainer span.winning')).map(el => el.textContent);
const currentLosers = Array.from(document.querySelectorAll('#kenoGame #boardContainer span.lost')).map(el => el.textContent);
if (currentWinners.length === 0 && currentLosers.length === 0) {
winningList.textContent = 'No numbers drawn yet.';
lostList.textContent = 'No numbers drawn yet.';
} else {
if (currentWinners.length > 0) {
currentWinners.sort((a, b) => parseInt(a) - parseInt(b)).forEach(number => { const li = document.createElement('li'); li.textContent = number; li.style.color = '#38b2ac'; winningList.appendChild(li); });
} else { winningList.textContent = 'No winning numbers.'; }
if (currentLosers.length > 0) {
currentLosers.sort((a, b) => parseInt(a) - parseInt(b)).forEach(number => { const li = document.createElement('li'); li.textContent = number; li.style.color = '#e53e3e'; lostList.appendChild(li); });
} else { lostList.textContent = 'No lost numbers.'; }
}
const sortedNumberKeys = Object.keys(numberPickCounts);
if (countSortOrder === 'byPick') {
sortedNumberKeys.sort((a, b) => numberPickCounts[b] - numberPickCounts[a]);
if(sortButton) sortButton.textContent = 'Sort by #';
} else { // 'byNumber'
sortedNumberKeys.sort((a, b) => parseInt(a) - parseInt(b));
if(sortButton) sortButton.textContent = 'Sort by Pick';
}
if (sortedNumberKeys.length > 0) {
sortedNumberKeys.forEach(number => { const span = document.createElement('span'); span.textContent = `${number}: ${numberPickCounts[number]}`; span.style.padding = '3px 5px'; span.style.backgroundColor = '#4a5568'; span.style.borderRadius = '3px'; numberCountDisplay.appendChild(span); });
} else {
numberCountDisplay.textContent = 'No numbers picked yet';
}
}
function _processKenoRoundResults(winnersSnapshot, losersSnapshot) {
if (winnersSnapshot.length === 0 && losersSnapshot.length === 0) { return; }
let numbersProcessed = false;
[...winnersSnapshot, ...losersSnapshot].forEach(number => {
numberPickCounts[number] = (numberPickCounts[number] || 0) + 1;
numbersProcessed = true;
});
if (numbersProcessed) {
localStorage.setItem(LS_KEY_PICK_COUNTS, JSON.stringify(numberPickCounts));
updateDisplay();
roundsProcessedInSession++;
}
}
const debounce = (func, delay) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => func.apply(this, a), delay); }; };
const processKenoRoundResultsDebounced = debounce(_processKenoRoundResults, 500);
const observer = new MutationObserver(() => {
if (roundsProcessedInSession >= roundsToPlay) { updateDisplay(); return; }
const currentWinnersSnapshot = Array.from(document.querySelectorAll('#kenoGame #boardContainer span.winning')).map(el => el.textContent);
const currentLosersSnapshot = Array.from(document.querySelectorAll('#kenoGame #boardContainer span.lost')).map(el => el.textContent);
if (currentWinnersSnapshot.length > 0 || currentLosersSnapshot.length > 0) {
processKenoRoundResultsDebounced(currentWinnersSnapshot, currentLosersSnapshot);
} else { updateDisplay(); }
});
function initializeKenoTrackerAfterElementsLoaded() {
numberPickCounts = JSON.parse(localStorage.getItem(LS_KEY_PICK_COUNTS)) || {};
countSortOrder = localStorage.getItem(LS_KEY_SORT_ORDER) || 'byPick';
createUI();
updateDisplay();
observer.observe(document.querySelector('#kenoGame #boardContainer'), { attributes: true, attributeFilter: ['class'], childList: true, subtree: true });
document.body.addEventListener('click', (e) => {
if (e.target.matches('#playBtn') || e.target.matches('#repeatBtn')) {
roundsToPlay = parseInt(document.getElementById('roundsAmount').textContent) || 1;
roundsProcessedInSession = 0;
} else if (e.target.matches('#clearBtn')) {
roundsToPlay = 0;
roundsProcessedInSession = 0;
}
});
const initialWinners = Array.from(document.querySelectorAll('#kenoGame #boardContainer span.winning')).map(el => el.textContent);
const initialLosers = Array.from(document.querySelectorAll('#kenoGame #boardContainer span.lost')).map(el => el.textContent);
if (initialWinners.length > 0 || initialLosers.length > 0) {
roundsToPlay = 1;
roundsProcessedInSession = 0;
processKenoRoundResultsDebounced(initialWinners, initialLosers);
}
}
const initInterval = setInterval(() => {
if (document.querySelector('#kenoGame #boardContainer')) {
clearInterval(initInterval);
initializeKenoTrackerAfterElementsLoaded();
}
}, 500);
})();