Adds a UI to merge additional patches of data on Coachless
// ==UserScript==
// @name Coachless Multi-Patch Statistics Merger
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Adds a UI to merge additional patches of data on Coachless
// @author FlayInAHook (and AI)
// @match https://coachless.gg/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const TARGET_URL = 'api/ChampionWinprob/GetGlobalItemStatistics';
// --- UI & STATE ---
// "0" means only the current patch. "1" means current + 1 previous, etc.
let additionalPatches = parseInt(localStorage.getItem('coachless_extra_patches')) ?? 1;
function createUI() {
const container = document.createElement('div');
container.id = 'patch-merger-ui';
container.innerHTML = `
<div style="font-weight: bold; margin-bottom: 5px; font-size: 12px; color: #00ceff; letter-spacing: 0.5px;">COACHLESS HELPER</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
<label style="font-size: 11px; white-space: nowrap;">Additional patches:</label>
<input type="number" id="extra-patch-input" value="${additionalPatches}" min="0" max="10"
style="width: 45px; background: #1a1a1a; color: #00ceff; border: 1px solid #333; border-radius: 4px; padding: 2px 4px; font-weight: bold; text-align: center;">
</div>
<div style="font-size: 9px; margin-top: 6px; color: #888; text-align: right;">Range: 0 - 10</div>
`;
Object.assign(container.style, {
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: 'rgba(10, 10, 10, 0.95)',
border: '1px solid #00ceff',
borderRadius: '10px',
padding: '12px',
zIndex: '99999',
color: 'white',
fontFamily: '"Segoe UI", Roboto, Helvetica, Arial, sans-serif',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
backdropFilter: 'blur(6px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(container);
document.getElementById('extra-patch-input').addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if (isNaN(val)) val = 0;
if (val < 0) val = 0;
if (val > 10) val = 10;
additionalPatches = val;
e.target.value = val;
localStorage.setItem('coachless_extra_patches', additionalPatches);
console.log(`[Coachless] Now set to fetch ${additionalPatches} additional patches.`);
});
}
// Inject UI when document is ready
const injectInterval = setInterval(() => {
if (document.body) {
createUI();
clearInterval(injectInterval);
}
}, 500);
// --- XHR INTERCEPTION ---
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this._url = url;
this._method = method;
return rawOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = async function(body) {
if (this._url && this._url.includes(TARGET_URL) && this._method === 'POST') {
console.group('%c[Coachless Multi-Interceptor]', 'color: #00ceff; font-weight: bold;');
try {
const baseBody = JSON.parse(body);
const startPatch = baseBody.commonFilters.patch.patch;
const requests = [];
console.log(`Main request: Patch ${startPatch}. Additional to fetch: ${additionalPatches}`);
// Loop: i=0 is current patch, i=1 is current-1, etc.
// We fetch up to (0 + additionalPatches)
for (let i = 0; i <= additionalPatches; i++) {
const targetPatch = startPatch - i;
if (targetPatch < 0) {
console.warn(`Stopping at patch 0. Cannot go negative.`);
break;
}
const newBody = JSON.parse(body);
newBody.commonFilters.patch.patch = targetPatch;
requests.push(
fetch(this._url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newBody)
}).then(r => r.json().catch(() => [])) // Handle potential empty/fail responses
);
}
const allResults = await Promise.all(requests);
console.log(`Retrieved ${allResults.length} total datasets.`);
const mergedData = mergeAllStats(allResults);
// Re-inject the combined data back into the original XHR object
const mergedString = JSON.stringify(mergedData);
Object.defineProperty(this, 'responseText', { value: mergedString, writable: true });
Object.defineProperty(this, 'response', { value: mergedString, writable: true });
Object.defineProperty(this, 'status', { value: 200, writable: true });
Object.defineProperty(this, 'readyState', { value: 4, writable: true });
console.log(`Merge complete: ${mergedData.length} unique items generated.`);
console.groupEnd();
// Fake the completion events so the UI updates
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('load'));
return;
} catch (err) {
console.error('[Coachless] Error during merge:', err);
console.groupEnd();
}
}
return rawSend.apply(this, arguments);
};
/**
* Standard Weighted Average Merger
*/
function mergeAllStats(resultsArray) {
const itemMap = new Map();
const weight = (val1, n1, val2, n2) => {
if (n1 + n2 === 0) return 0;
return ((val1 * n1) + (val2 * n2)) / (n1 + n2);
};
resultsArray.forEach(patchData => {
if (!Array.isArray(patchData)) return;
patchData.forEach(item => {
if (itemMap.has(item.itemId)) {
const existing = itemMap.get(item.itemId);
const totalOccur = existing.occurrence + item.occurrence;
existing.winrateObserved = weight(existing.winrateObserved, existing.occurrence, item.winrateObserved, item.occurrence);
existing.winrateExpected = weight(existing.winrateExpected, existing.occurrence, item.winrateExpected, item.occurrence);
existing.wpaOverall = weight(existing.wpaOverall, existing.occurrence, item.wpaOverall, item.occurrence);
existing.bias = weight(existing.bias, existing.occurrence, item.bias, item.occurrence);
existing.averagePurchaseTime = weight(existing.averagePurchaseTime, existing.occurrence, item.averagePurchaseTime, item.occurrence);
existing.occurrence = totalOccur;
} else {
itemMap.set(item.itemId, { ...item });
}
});
});
return Array.from(itemMap.values());
}
})();