// ==UserScript==
// @name Nitro Type WS Logger (UI only on /race)
// @namespace https://nitrotype.com/
// @version 3.4
// @description Captures all WS frames site-wide; shows roster panel ONLY on /race (with Copy WS)
// @match *://www.nitrotype.com/race*
// @match *://www.nitrotype.com/race/*
// @exclude *://www.nitrotype.com/racer*
// @grant none
// @run-at document-idle
// @author Day
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'nt-ws-roster-panel';
const CONTENT_ID = 'nt-ws-roster-content';
const MAX_WS_LOG = 2000; // cap for raw frames
// Live race bits we keep (nitros/errors only)
const raceLive = new Map(); // uid -> { n, e }
// Profiles from setup/joined
const profiles = new Map(); // uid -> profile
// Raw WS messages store (for Copy WS)
const wsLog = []; // {ts: number, data: string}
let myUserId = null;
let ui = null;
let copyNote = null;
// ---------- Route helpers (SPA-safe) ----------
const isRace = () => location.pathname.startsWith('/race');
function onRouteChange() {
if (isRace()) mountPanel();
else unmountPanel();
}
// watch pushState/replaceState + popstate
(function hookHistory(){
const ps = history.pushState, rs = history.replaceState;
history.pushState = function() { const r = ps.apply(this, arguments); queueMicrotask(onRouteChange); return r; };
history.replaceState = function() { const r = rs.apply(this, arguments); queueMicrotask(onRouteChange); return r; };
window.addEventListener('popstate', () => queueMicrotask(onRouteChange));
})();
// also poll occasionally in case the app swaps without history events
setInterval(() => { onRouteChange(); }, 750);
// ---------- UI ----------
function ensurePanelElement() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement('div');
panel.id = PANEL_ID;
Object.assign(panel.style, {
position: 'fixed',
top: '10px',
right: '10px',
width: '360px',
maxHeight: '460px',
background: '#000',
color: '#0f0',
fontSize: '11px',
fontFamily: 'monospace',
padding: '8px',
border: '2px solid lime',
borderRadius: '6px',
zIndex: '999999',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: '1.35',
boxSizing: 'border-box',
});
const content = document.createElement('div');
content.id = CONTENT_ID;
content.textContent = '🟢 WS Logger Ready...\n(Waiting for roster & updates on /race)';
panel.appendChild(content);
// bottom toolbar
const toolbar = document.createElement('div');
Object.assign(toolbar.style, {
position: 'sticky',
bottom: '0',
left: '0',
right: '0',
marginTop: '6px',
paddingTop: '6px',
background: 'linear-gradient(to top, #000, rgba(0,0,0,0.6))',
display: 'flex',
gap: '6px',
alignItems: 'center',
borderTop: '1px solid #0f0',
});
const copyBtn = document.createElement('button');
copyBtn.textContent = '📋 Copy WS';
Object.assign(copyBtn.style, {
background: '#0f0',
color: '#000',
border: 'none',
padding: '4px 8px',
fontFamily: 'monospace',
fontSize: '11px',
borderRadius: '4px',
cursor: 'pointer',
});
copyBtn.addEventListener('click', copyAllWS);
copyNote = document.createElement('span');
Object.assign(copyNote.style, {
color: '#9f9',
fontSize: '10px',
opacity: '0.9',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '220px',
});
copyNote.textContent = 'WS captured: 0';
toolbar.appendChild(copyBtn);
toolbar.appendChild(copyNote);
panel.appendChild(toolbar);
return panel;
}
function mountPanel() {
if (!document.body) return;
if (ui && document.body.contains(ui)) return;
ui = ensurePanelElement();
document.body.appendChild(ui);
updateDisplay(); // render latest when (re)mounting
}
function unmountPanel() {
if (ui && ui.parentNode) {
ui.parentNode.removeChild(ui);
}
}
const fmtNum = (n) => (typeof n === 'number' ? n.toLocaleString() : undefined);
const asNum = (n) => (typeof n === 'number' ? n : undefined);
function nameFor(uid) {
const p = profiles.get(uid);
if (p?.displayName && String(p.displayName).trim()) return String(p.displayName).trim();
if (p?.username) return p.username;
if (typeof uid === 'string' && uid.startsWith('robot')) return 'Bot';
return String(uid);
}
function lineIf(label, v, suffix = '') {
if (v === undefined || v === null || v === '') return null;
return ` ${label}: ${v}${suffix}`;
}
function liveLine(uid) {
const r = raceLive.get(uid) || {};
const hasN = typeof r.n === 'number';
const hasE = typeof r.e === 'number';
if (!hasN && !hasE) return null;
return ` This Race: ${hasN ? `${r.n} nitros` : ''}${hasN && hasE ? ', ' : ''}${hasE ? `${r.e} errors` : ''}`;
}
function extrasLine(p) {
const extras = [];
if (typeof p.avgAcc === 'number') extras.push(`Avg Acc ${p.avgAcc}%`);
if (typeof p.wampusWins === 'number') extras.push(`${p.wampusWins} Wampus wins`);
if (typeof p.consecDaysRaced === 'number') extras.push(`${p.consecDaysRaced} consec days`);
if (!extras.length) return null;
return ` ${extras.join(' · ')}`;
}
function updateDisplay() {
if (!isRace()) return; // UI only on /race
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
const content = document.getElementById(CONTENT_ID);
if (!content) return;
const uids = new Set([...profiles.keys(), ...raceLive.keys()]);
// Sort: you first, then alphabetical
const sorted = [...uids].sort((a, b) => {
if (a === myUserId) return -1;
if (b === myUserId) return 1;
return nameFor(a).toLowerCase().localeCompare(nameFor(b).toLowerCase());
});
const out = ['📋 Race Roster (Account Stats + n/e)\n'];
if (!sorted.length) {
out.push('(waiting for WS setup/joined/update...)');
} else {
for (const uid of sorted) {
const p = profiles.get(uid) || {};
const you = uid === myUserId ? '🟩 You' : `👤 ${nameFor(uid)}`;
out.push(you);
const cls = asNum(p.inClass);
const sess = asNum(p.sessionRaces);
const avg = asNum(p.avgSpeed);
const hi = asNum(p.highestSpeed);
const total = asNum(p.racesPlayed);
const longS = asNum(p.longestSession);
const lines = [
lineIf('Class', cls),
lineIf('Session Races', fmtNum(sess)),
lineIf('Avg WPM', fmtNum(avg)),
lineIf('High WPM', fmtNum(hi)),
lineIf('Total Races', fmtNum(total)),
lineIf('Longest Session', fmtNum(longS)),
extrasLine(p),
liveLine(uid),
].filter(Boolean);
if (lines.length) out.push(...lines);
out.push('');
}
}
content.innerHTML = out.join('\n');
if (copyNote) copyNote.textContent = `WS captured: ${wsLog.length}`;
}
// ---------- Copy raw WS ----------
async function copyAllWS() {
const lines = wsLog.map(({ ts, data }) => `[${new Date(ts).toISOString()}] ${String(data)}`);
const blob = lines.join('\n');
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(blob);
} else {
const ta = document.createElement('textarea');
ta.value = blob;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
if (copyNote) copyNote.textContent = `Copied ${lines.length} WS msgs`;
} catch (e) {
if (copyNote) copyNote.textContent = `Copy failed: ${e?.message || e}`;
}
}
// ---------- WS hook (captures all pages) ----------
const nativeAddEventListener = WebSocket.prototype.addEventListener;
WebSocket.prototype.addEventListener = function (type, listener, options) {
if (type === 'message') {
const wrapped = function (event) {
try {
// store everything raw
wsLog.push({ ts: Date.now(), data: event.data });
if (wsLog.length > MAX_WS_LOG) wsLog.splice(0, wsLog.length - MAX_WS_LOG);
// parse race frames for panel
let raw = event.data;
if (typeof raw === 'string' && (raw.startsWith('4') || raw.startsWith('5'))) {
const body = raw.slice(1);
let msg;
try { msg = JSON.parse(body); } catch { /* not JSON */ }
if (msg?.stream === 'race') {
const { msg: kind, payload = {} } = msg;
// setup/joined → harvest profiles
if (kind === 'setup' && Array.isArray(payload.racers)) {
for (const r of payload.racers) {
const uid = r.userID ?? r.u ?? r.profile?.userID;
if (uid != null && r.profile) profiles.set(uid, r.profile);
}
updateDisplay();
}
if (kind === 'joined' && payload) {
const uid = payload.userID ?? payload.u ?? payload.profile?.userID;
if (uid != null && payload.profile) profiles.set(uid, payload.profile);
updateDisplay();
}
// update → keep n/e + learn myUserId
if (kind === 'update' && Array.isArray(payload.racers)) {
for (const r of payload.racers) {
if (r.u == null) continue;
const prev = raceLive.get(r.u) || {};
const next = {
n: typeof r.n === 'number' ? r.n : prev.n,
e: typeof r.e === 'number' ? r.e : prev.e
};
raceLive.set(r.u, next);
if (myUserId === null && typeof r.u === 'number') myUserId = r.u;
}
updateDisplay();
}
}
}
} catch {
// never break the page/socket
}
return listener.call(this, event);
};
return nativeAddEventListener.call(this, type, wrapped, options);
}
return nativeAddEventListener.call(this, type, listener, options);
};
// Initial mount state
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onRouteChange);
} else {
onRouteChange();
}
})();