// ==UserScript==
// @name Nitro Type WS Logger - Day
// @namespace https://nitrotype.com/
// @version 5.1
// @description This Nitro Type script allows you to see everyone's race stats during a race. That includes everyone's current race session, avg speed, total races, etc.
// @match *://www.nitrotype.com/*
// @exclude *://www.nitrotype.com/racer*
// @grant none
// @run-at document-start
// @author Day
// @license
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'nt-rightcards-panel';
const CONTENT_ID = 'nt-rightcards-content';
const EVENTS_ID = 'nt-rightcards-events';
const MAX_WS_LOG = 2000;
const MAX_EVENTS = 40;
const MAX_SLOTS = 5;
const profiles = new Map(); // uid -> profile
const raceLive = new Map(); // uid -> {n,e}
const wsLog = [];
const eventsLog = [];
const slotOf = new Map(); // uid -> slot index
const slotUID = new Array(MAX_SLOTS).fill(null); // slot -> uid
const freeSlots = []; // queue of freed slot indexes (FIFO)
let copyNote = null;
let myUserId = null;
const isRace = () => location.pathname.startsWith('/race');
// ---------- routing ----------
function onRouteChange() { isRace() ? mountPanel() : unmountPanel(); }
(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; };
addEventListener('popstate', () => queueMicrotask(onRouteChange));
})();
setInterval(onRouteChange, 750);
// ---------- ui ----------
function ensurePanel() {
if (document.getElementById(PANEL_ID)) return;
const panel = document.createElement('div');
panel.id = PANEL_ID;
Object.assign(panel.style, {
position: 'fixed',
top: '30px',
right: '0',
width: '260px',
height: '480px',
background: 'linear-gradient(180deg,#d50000 0%, #5a0000 50%, #000000 100%)',
color: '#fff',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '10px',
lineHeight: '1.2',
padding: '6px 8px',
borderLeft: '2px solid #fff',
borderRadius: '8px 0 0 8px',
boxShadow: '0 0 12px rgba(213,0,0,0.6)',
zIndex: '999999',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
});
const header = document.createElement('div');
header.textContent = 'Race Roster (WS)';
Object.assign(header.style, {
fontWeight: '800',
fontSize: '11px',
paddingBottom: '4px',
borderBottom: '1px solid rgba(255,255,255,0.5)',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: '#fff'
});
const spacer = document.createElement('div'); Object.assign(spacer.style, { flex: '1 1 auto' });
const copyBtn = document.createElement('button');
copyBtn.textContent = '📋';
Object.assign(copyBtn.style, {
background: '#fff', color: '#b50000', border: 'none', padding: '2px 5px',
fontSize: '9px', borderRadius: '5px', cursor: 'pointer', fontWeight: '700'
});
copyBtn.addEventListener('click', copyAllWS);
copyNote = document.createElement('div');
copyNote.textContent = 'WS: 0';
Object.assign(copyNote.style, { fontSize: '9px', opacity: '0.9', color: '#fff' });
header.append(spacer, copyBtn, copyNote);
const content = document.createElement('div');
content.id = CONTENT_ID;
Object.assign(content.style, {
flex: '1 1 auto',
overflowY: 'auto',
paddingTop: '4px',
paddingRight: '3px',
scrollbarWidth: 'thin'
});
const events = document.createElement('div');
events.id = EVENTS_ID;
Object.assign(events.style, {
borderTop: '1px solid rgba(255,255,255,0.5)',
paddingTop: '4px',
paddingBottom: '2px',
fontSize: '9px',
whiteSpace: 'nowrap',
overflowX: 'auto',
display: 'flex',
gap: '6px',
alignItems: 'center',
color: '#fff'
});
const evLabel = document.createElement('div');
evLabel.textContent = 'Events:';
Object.assign(evLabel.style, { fontWeight: '700', color: '#fff' });
events.appendChild(evLabel);
panel.append(header, content, events);
document.body.appendChild(panel);
}
function mountPanel() { if (!document.body) return; ensurePanel(); renderAll(); }
function unmountPanel() {
const p = document.getElementById(PANEL_ID);
if (p && p.parentNode) p.parentNode.removeChild(p);
slotOf.clear(); slotUID.fill(null); freeSlots.length = 0;
profiles.clear(); raceLive.clear();
}
// ---------- rendering ----------
const fmtNum = n => (typeof n === 'number' ? n.toLocaleString() : '—');
const asNum = n => (typeof n === 'number' ? n : undefined);
function cleanDisplayName(str, fallback) {
const s = (str ?? '').toString().trim();
if (/^you$/i.test(s)) return fallback || '';
return s || fallback || '';
}
function nameFor(uid) {
const p = profiles.get(uid) || {};
const user = (p.username || '').toString().trim();
return cleanDisplayName(p.displayName, user || String(uid));
}
function cardHTML(uid) {
const p = profiles.get(uid) || {};
const live = raceLive.get(uid) || {};
const name = nameFor(uid);
const avg = fmtNum(asNum(p.avgSpeed));
const hi = fmtNum(asNum(p.highestSpeed));
const cls = (typeof p.inClass === 'number') ? p.inClass : '—';
const sess = fmtNum(asNum(p.sessionRaces));
const tot = fmtNum(asNum(p.racesPlayed));
const ne = [(typeof live.n === 'number') ? `${live.n} nitros` : null,
(typeof live.e === 'number') ? `${live.e} errors` : null].filter(Boolean).join(' / ') || '—';
return `
<div style="margin:4px 0; padding:4px; border:1px solid rgba(255,255,255,0.3);
background: rgba(0,0,0,0.35); border-radius:6px;">
<div style="font-weight:700; font-size:11px; margin-bottom:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#fff;">${name}</div>
<div>Avg ${avg} • High ${hi} • Class ${cls}</div>
<div>Session ${sess} • Total ${tot}</div>
<div>This Race (n/e): ${ne}</div>
</div>
`;
}
function renderAll() {
const container = document.getElementById(CONTENT_ID);
if (!container) return;
const cards = [];
for (let i = 0; i < MAX_SLOTS; i++) {
const uid = slotUID[i];
if (uid == null) {
cards.push(`
<div style="margin:4px 0; padding:4px; border:1px dashed rgba(255,255,255,0.3);
background: rgba(0,0,0,0.25); border-radius:6px; opacity:.65;">
<div style="font-weight:700; color:#fff;">Slot ${i + 1}</div>
<div>Waiting…</div>
</div>`);
} else {
cards.push(cardHTML(uid));
}
}
container.innerHTML = cards.join('');
if (copyNote) copyNote.textContent = `WS: ${wsLog.length}`;
renderEvents();
}
// ---------- slotting & recycling ----------
function getOrAssignSlot(uid) {
if (slotOf.has(uid)) return slotOf.get(uid);
// Prefer reusing the exact freed slot
let slot;
if (freeSlots.length) {
slot = freeSlots.shift();
} else {
for (let i = 0; i < MAX_SLOTS; i++) {
if (slotUID[i] == null) { slot = i; break; }
}
if (slot == null) return -1; // already showing 5
}
slotUID[slot] = uid;
slotOf.set(uid, slot);
return slot;
}
function removeUID(uid) {
if (!slotOf.has(uid)) return;
const slot = slotOf.get(uid);
slotOf.delete(uid);
if (slotUID[slot] === uid) {
slotUID[slot] = null;
freeSlots.push(slot); // record freed slot for the next joiner
}
raceLive.delete(uid);
}
// ---------- events ----------
function pushEvent(kind, summary, ts = Date.now()) {
eventsLog.push({ ts, kind, summary });
if (eventsLog.length > MAX_EVENTS) eventsLog.splice(0, eventsLog.length - MAX_EVENTS);
renderEvents();
}
function renderEvents() {
const box = document.getElementById(EVENTS_ID);
if (!box) return;
while (box.children.length > 1) box.removeChild(box.lastChild);
const last = eventsLog.slice(-12);
for (const ev of last) {
const chip = document.createElement('div');
chip.textContent = `[${new Date(ev.ts).toLocaleTimeString()}] ${ev.kind}: ${ev.summary}`;
Object.assign(chip.style, {
padding: '1px 4px',
background: 'rgba(213,0,0,0.4)',
border: '1px solid rgba(255,255,255,0.4)',
borderRadius: '999px',
whiteSpace: 'nowrap',
fontSize: '9px',
color: '#fff'
});
box.appendChild(chip);
}
}
// ---------- clipboard ----------
async function copyAllWS() {
const blob = wsLog.map(({ ts, data }) => `[${new Date(ts).toISOString()}] ${data}`).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(); }
} finally {
if (copyNote) copyNote.textContent = `Copied ${wsLog.length} WS msgs`;
}
}
// ---------- ws capture ----------
const origAddEventListener = WebSocket.prototype.addEventListener;
const origOnMessageDesc = Object.getOwnPropertyDescriptor(WebSocket.prototype, 'onmessage');
function cleanProfile(uid, prof) {
profiles.set(uid, {
...profiles.get(uid),
...prof,
displayName: cleanDisplayName(prof?.displayName, prof?.username || String(uid)),
});
}
// Heuristics to detect a "leave" inside update payloads
function isLeaveObj(r) {
if (!r) return false;
if (r.left === true || r.left === 1) return true;
if (r.l === true || r.l === 1) return true;
if (typeof r.status === 'string' && r.status.toLowerCase() === 'left') return true;
if (r.action === 'left' || r.event === 'left') return true;
return false;
}
function handleRaceMsg(kind, payload) {
// Full roster on setup
if (kind === 'setup' && Array.isArray(payload?.racers)) {
const present = new Set();
freeSlots.length = 0; // new race cycle
for (const r of payload.racers) {
const uid = r.userID ?? r.u ?? r.profile?.userID;
if (uid == null) continue;
if (r.profile) cleanProfile(uid, r.profile);
if (typeof r.n === 'number' || typeof r.e === 'number') {
const prev = raceLive.get(uid) || {};
raceLive.set(uid, { n: (typeof r.n === 'number' ? r.n : prev.n), e: (typeof r.e === 'number' ? r.e : prev.e) });
}
if (myUserId === null && typeof uid === 'number') myUserId = uid;
present.add(uid);
}
// Assign/keep slots for everyone present
for (const uid of present) getOrAssignSlot(uid);
renderAll();
return;
}
// A single racer joined → reuse freed slot if any
if (kind === 'joined' && payload?.profile) {
const uid = payload.userID ?? payload.u ?? payload.profile?.userID;
if (uid != null) {
cleanProfile(uid, payload.profile);
getOrAssignSlot(uid);
renderAll();
}
return;
}
// Periodic updates
if (kind === 'update' && Array.isArray(payload?.racers)) {
for (const r of payload.racers) {
const uid = r.u ?? r.userID ?? r.profile?.userID;
if (uid == null) continue;
if (isLeaveObj(r)) {
// free their slot so the next joiner inherits it
removeUID(uid);
continue;
}
// regular stat update
const prev = raceLive.get(uid) || {};
raceLive.set(uid, {
n: (typeof r.n === 'number' ? r.n : prev.n),
e: (typeof r.e === 'number' ? r.e : prev.e),
});
// If this racer wasn't slotted yet (e.g., joined quietly), slot them now
if (!slotOf.has(uid)) getOrAssignSlot(uid);
}
renderAll();
return;
}
// Explicit leave message (some servers send this)
if ((kind === 'left' || kind === 'leave') && (payload?.u != null || payload?.userID != null)) {
const uid = payload.u ?? payload.userID;
removeUID(uid);
renderAll();
return;
}
// Include any top-level events
const maybe = payload?.events ?? payload?.event ?? null;
if (maybe) {
const arr = Array.isArray(maybe) ? maybe : [maybe];
for (const ev of arr) {
const summary = (typeof ev === 'object') ? JSON.stringify(ev) : String(ev);
pushEvent(kind, summary);
}
}
}
function handleMessageData(raw) {
try {
wsLog.push({ ts: Date.now(), data: raw });
if (wsLog.length > MAX_WS_LOG) wsLog.splice(0, wsLog.length - MAX_WS_LOG);
if (typeof raw !== 'string' || (!raw.startsWith('4') && !raw.startsWith('5'))) return;
let msg; try { msg = JSON.parse(raw.slice(1)); } catch { return; }
if (msg?.stream === 'race') handleRaceMsg(msg.msg, msg.payload || {});
const root = msg?.events ?? msg?.event ?? null;
if (root) {
const arr = Array.isArray(root) ? root : [root];
for (const ev of arr) {
const summary = (typeof ev === 'object') ? JSON.stringify(ev) : String(ev);
pushEvent(msg?.msg || 'event', summary);
}
}
if (copyNote) copyNote.textContent = `WS: ${wsLog.length}`;
} catch {}
}
WebSocket.prototype.addEventListener = function (type, listener, opts) {
if (type === 'message') {
const wrapped = (evt) => { handleMessageData(evt.data); return listener.call(this, evt); };
return origAddEventListener.call(this, type, wrapped, opts);
}
return origAddEventListener.call(this, type, listener, opts);
};
Object.defineProperty(WebSocket.prototype, 'onmessage', {
configurable: true, enumerable: true,
get() { return origOnMessageDesc && origOnMessageDesc.get ? origOnMessageDesc.get.call(this) : this._nt_onmessage; },
set(handler) {
const wrapped = handler ? (evt) => { handleMessageData(evt.data); return handler.call(this, evt); } : null;
if (origOnMessageDesc && origOnMessageDesc.set) origOnMessageDesc.set.call(this, wrapped);
else this._nt_onmessage = wrapped;
}
});
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', onRouteChange);
else onRouteChange();
})();