// ==UserScript==
// @name ManipFactor for EtternaOnline
// @namespace http://tampermonkey.net/
// @version 0.1.5
// @description Estimates the amount of manip from the replay data.
// @author U1wknUzeU6, OpakyL
// @match https://etternaonline.com/*
// @grant none
// @updateURL https://raw.githubusercontent.com/MaidOfFire/ManipFactorEtterna/main/manipfactor.etternaonline.js
// @downloadURL https://raw.githubusercontent.com/MaidOfFire/ManipFactorEtterna/main/manipfactor.etternaonline.js
// @homepageURL https://github.com/MaidOfFire/ManipFactorEtterna
// @supportURL https://github.com/MaidOfFire/ManipFactorEtterna/issues/new
// ==/UserScript==
(function() {
'use strict';
const scorePagePattern = /^https:\/\/etternaonline\.com\/users\/[^\/]+\/scores\/[^\/]+\/?$/;
let locationChangeTimeout;
// Helper function to calculate weighted mean
function weightedMean(values, weights) {
const weightedSum = values.reduce((sum, value, index) => sum + value * weights[index], 0);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
return weightedSum / weightSum;
}
// Function to calculate manipulation factor and Left hand MF, Right hand MF
function getManipFactor(replayData) {
const key0Data = [];
const key1Data = [];
const key2Data = [];
const key3Data = [];
replayData.forEach(([time, error, keyLabel]) => {
const timeInMs = time * 1000;
if (keyLabel === 0) {
key0Data.push({ time: timeInMs, error });
} else if (keyLabel === 1) {
key1Data.push({ time: timeInMs, error });
} else if (keyLabel === 2) {
key2Data.push({ time: timeInMs, error });
} else if (keyLabel === 3) {
key3Data.push({ time: timeInMs, error });
}
});
const k0to1Deviations = calculateDeviations(key0Data, key1Data);
const k1to0Deviations = calculateDeviations(key1Data, key0Data);
const k2to3Deviations = calculateDeviations(key2Data, key3Data);
const k3to2Deviations = calculateDeviations(key3Data, key2Data);
const mfk0to1 = k0to1Deviations.yValues.reduce((a, b) => a + b, 0) / k0to1Deviations.yValues.length;
const mfk1to0 = k1to0Deviations.yValues.reduce((a, b) => a + b, 0) / k1to0Deviations.yValues.length;
const mfk2to3 = k2to3Deviations.yValues.reduce((a, b) => a + b, 0) / k2to3Deviations.yValues.length;
const mfk3to2 = k3to2Deviations.yValues.reduce((a, b) => a + b, 0) / k3to2Deviations.yValues.length;
// LH-MF (Weighted average of 0→1 and 1→0)
const leftHandWeights = [
k0to1Deviations.yValues.length,
k1to0Deviations.yValues.length
];
const leftHandMF = weightedMean([mfk0to1, mfk1to0], leftHandWeights);
// RH-MF (Weighted average of 2→3 and 3→2)
const rightHandWeights = [
k2to3Deviations.yValues.length,
k3to2Deviations.yValues.length
];
const rightHandMF = weightedMean([mfk2to3, mfk3to2], rightHandWeights);
// Overall manip factor
const overallWeights = [
k0to1Deviations.yValues.length,
k1to0Deviations.yValues.length,
k2to3Deviations.yValues.length,
k3to2Deviations.yValues.length
];
const manipFactor = weightedMean([mfk0to1, mfk1to0, mfk2to3, mfk3to2], overallWeights);
return { manipFactor, leftHandMF, rightHandMF };
}
// Function to calculate ManipScore based on manip factor
function manipScoreFunction(manipFactor) {
if (manipFactor <= 0.1) {
return 1;
} else {
const C = 2.3;
const k = 10;
const b = -0.1;
return Math.exp(-k * Math.pow(manipFactor + b, C));
}
}
// Function to calculate deviations between keys
function calculateDeviations(keyAData, keyBData) {
const eps = 0.1;
const xValues = [];
const yValues = [];
const sortedKeyAData = keyAData.slice().sort((a, b) => a.time - b.time);
const sortedKeyBData = keyBData.slice().sort((a, b) => a.time - b.time);
const timesA = sortedKeyAData.map(({ time }) => time);
const timesB = sortedKeyBData.map(({ time }) => time);
const diffA = timesA.slice(1).map((time, index) => time - timesA[index]);
const diffB = timesB.slice(1).map((time, index) => time - timesB[index]);
const nonZeroDiffA = diffA.filter(diff => diff !== 0);
const nonZeroDiffB = diffB.filter(diff => diff !== 0);
const lowerPercentileA = percentile(nonZeroDiffA, 5);
const upperPercentileA = percentile(nonZeroDiffA, 95);
const lowerPercentileB = percentile(nonZeroDiffB, 5);
const upperPercentileB = percentile(nonZeroDiffB, 95);
const filteredDiffA = nonZeroDiffA.filter(diff => diff > lowerPercentileA - eps && diff < upperPercentileA + eps);
const filteredDiffB = nonZeroDiffB.filter(diff => diff > lowerPercentileB - eps && diff < upperPercentileB + eps);
const k0AvgInterval = filteredDiffA.reduce((sum, diff) => sum + diff, 0) / filteredDiffA.length;
const k1AvgInterval = filteredDiffB.reduce((sum, diff) => sum + diff, 0) / filteredDiffB.length;
let avgInterval = (k0AvgInterval + k1AvgInterval) / 2;
avgInterval /= 2;
sortedKeyAData.forEach(({ time: timeA, error: errorA }) => {
const lastKeyBItem = sortedKeyBData.filter(({ time }) => time < timeA - eps).pop();
if (lastKeyBItem) {
const { time: timeB, error: errorB } = lastKeyBItem;
const deviation = (errorB - errorA) / avgInterval;
const absDeviation = Math.abs(deviation);
if (absDeviation <= 1.5) {
xValues.push(timeA);
yValues.push(absDeviation);
}
}
});
return { xValues, yValues };
}
// Helper function to calculate percentiles
function percentile(arr, p) {
if (arr.length === 0) return 0;
arr.sort((a, b) => a - b);
const index = (p / 100) * (arr.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= arr.length) return arr[lower];
return arr[lower] * (1 - weight) + arr[upper] * weight;
}
// Convert manip factor to color (based on Lua logic)
function byMF(x) {
const hue = Math.max(0, 120 - (x * 300)); // hue from green to red
const saturation = 0.9;
const brightness = 0.9;
return `hsl(${hue}, ${saturation * 100}%, ${brightness * 100}%)`;
}
// Fetch replay data from the page
function fetchReplayData(callback) {
const replayData = window.$nuxt?.$children[1]?.$children[1]?.$children[0]?.replay;
if (replayData) {
callback(replayData);
} else {
console.warn("Replay data not found yet, retrying...");
setTimeout(() => fetchReplayData(callback), 500);
}
}
// Initialize and display manip score with hover details
const initializeManipScoreDisplay = () => {
fetchReplayData(replayData => {
const { manipFactor, leftHandMF, rightHandMF } = getManipFactor(replayData);
const manipScorePercent = (manipFactor * 100).toFixed(2); // convert to percentage
// Create new element to display ManipScore
const gradeOverallWrapper = document.querySelector(".grade-overall-wrapper");
if (gradeOverallWrapper) {
const manipDiv = document.createElement("div");
manipDiv.style.display = "flex";
manipDiv.style.flexDirection = "column";
manipDiv.style.alignItems = "flex-start"; // Align to left
const manipLabel = document.createElement("div");
manipLabel.className = "msd font-small-bold";
manipLabel.innerText = "MF"; // Label for Manipulation Factor
manipLabel.style.textAlign = "left"; // Align label to the left
manipDiv.appendChild(manipLabel);
const manipScoreElement = document.createElement("h6");
manipScoreElement.innerText = `${manipScorePercent}%`; // Display ManipScore in percentage
manipScoreElement.style.color = byMF(manipFactor); // Apply color based on factor
manipDiv.appendChild(manipScoreElement);
// Create tooltip for LH-MF and RH-MF values
const tooltip = document.createElement("div");
tooltip.style.position = "absolute";
tooltip.style.padding = "10px";
tooltip.style.background = "rgba(0, 0, 0, 0.8)";
tooltip.style.color = "#fff";
tooltip.style.borderRadius = "5px";
tooltip.style.display = "none";
tooltip.style.zIndex = "1000";
tooltip.innerHTML = `
Details:
Left Hand: ${(leftHandMF * 100).toFixed(2)}%
Right Hand: ${(rightHandMF * 100).toFixed(2)}%
`;
// Show tooltip on hover
manipDiv.addEventListener("mouseenter", function(e) {
tooltip.style.display = "block";
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY + 10}px`;
});
// Move tooltip with mouse
manipDiv.addEventListener("mousemove", function(e) {
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY + 10}px`;
});
// Hide tooltip on mouseleave
manipDiv.addEventListener("mouseleave", function() {
tooltip.style.display = "none";
});
// Append the new element and tooltip to the page
gradeOverallWrapper.appendChild(manipDiv);
document.body.appendChild(tooltip);
}
});
};
const initializeWrapper = () => {
clearTimeout(locationChangeTimeout);
// Set a debounce to prevent multiple callings
locationChangeTimeout = setTimeout(() => {
if (scorePagePattern.test(window.location.href)) {
// Check if on score page and display ManipScore
if (scorePagePattern.test(window.location.href)) {
initializeManipScoreDisplay();
}
}
}, 300);
}
// First init of script
initializeWrapper();
// Monkey-patch history methods to detect navigation
(function() {
const _pushState = history.pushState;
const _replaceState = history.replaceState;
history.pushState = function(state, title, url) {
const result = _pushState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return result;
};
history.replaceState = function(state, title, url) {
const result = _replaceState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return result;
};
window.addEventListener('popstate', function() {
window.dispatchEvent(new Event('locationchange'));
});
})();
// Listen for custom 'locationchange' event
window.addEventListener('locationchange', function() {
initializeWrapper();
});
})();