您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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(); })();