/* global React, ReactDOM */
const { useState, useEffect, useMemo, useRef } = React;
// ---------------- Shared bits ----------------
// `np` is mutated when fresh data arrives from data.js. Components close over
// this binding and re-render via App's setState when the data updates.
let np = window.NP_DATA;
function fmt(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
function CountryFlag({ code }) {
return {code};
}
function WeaponGlyph({ id, size = 28 }) {
// Stylized SVG silhouettes - rust-flavored, monochrome
const s = size;
const stroke = '#bce505';
const paths = {
// Rifles. Each has a distinctive silhouette:
// AK = angled banana mag dropping down + wood stock + gas tube on top
// LR-300 = AR-style straight mag + telescoping stock + picatinny rail
// Bolt = bolt-action with prominent scope and short straight mag
// M39 = AK-like body with long scope rail
// L96 = long sniper with full-length scope
// Semi = stubby semi-auto rifle, no mag protrusion
ak47: ,
lr300: ,
bolt: ,
semi: ,
l96: ,
m39: ,
// SMG
mp5: ,
thompson: ,
customsmg: ,
// LMG
m249: ,
hmlmg: ,
// Pistols
python: ,
revolver: ,
m92: ,
semipistol: ,
eoka: ,
nailgun: ,
// Shotguns
spas12: ,
pumpshotgun: ,
doublebarrel: ,
waterpipe: ,
// Bows / xbow / speargun
bow: ,
crossbow: ,
speargun: ,
spear: ,
// Knives / swords / clubs
knife: ,
sword: ,
mace: ,
// Tools
jackhammer: ,
chainsaw: ,
hatchet: ,
pickaxe: ,
hammer: ,
rock: ,
torch: ,
// Launchers / explosives
rocketlauncher: ,
grenadelauncher: ,
snowballgun: ,
grenade: ,
beancan: ,
smoke: ,
c4: ,
satchel: ,
rocket: ,
// Default
custom: ,
};
return (
);
}
function Sparkline({ data, color = '#bce505', width = 80, height = 22 }) {
const max = Math.max(...data, 1);
const pts = data.map((v, i) => `${(i / (data.length - 1)) * width},${height - (v / max) * height}`).join(' ');
return (
);
}
// ---------------- Top bar ----------------
function TopBar({ route, setRoute }) {
return (
);
}
// ---------------- LEADERBOARD ----------------
function Podium({ top3, onPick, killsKey = 'kills' }) {
// Order: 2nd left, 1st center, 3rd right
const slots = [top3[1], top3[0], top3[2]];
const heights = [180, 240, 150];
const ranks = [2, 1, 3];
return (
{slots.map((p, i) => p ? (
) :
)}
);
}
// Compute the scoped kills value for a player given the active scope.
// 'wipe' => current totals. '24h' => last day of sparkline.
// '7d' => sum of last 7 days. '24h'/'7d' values are bounded by the sparkline,
// so they reflect activity since the plugin started — partial during the
// first 14 days post-wipe. 'all' uses lifetime fields via projectAllTime.
function scopedKills(p, scope) {
const sp = p.sparkline || [];
if (scope === '24h') return sp.length ? sp[sp.length - 1] : 0;
if (scope === '7d') return sp.slice(-7).reduce((s, v) => s + v, 0);
return p.kills;
}
// Project lifetime fields onto the player so downstream sort/display reads
// them naturally when scope='all'. Falls back to current-wipe values for
// any field the backend hasn't started emitting yet.
function projectAllTime(p) {
const lt = p.lifetime;
if (!lt) return p;
return {
...p,
kills: lt.kills ?? p.kills,
deaths: lt.deaths ?? p.deaths,
kd: lt.kd ?? p.kd,
headshotPct: lt.headshotPct ?? p.headshotPct,
damageDealt: lt.damageDealt ?? p.damageDealt,
playtime: lt.playtime ?? p.playtime,
resources: lt.resources ?? p.resources,
structuresBuilt: lt.structuresBuilt ?? p.structuresBuilt,
basesRaided: lt.basesRaided ?? p.basesRaided,
longestKill: lt.longestKill ?? p.longestKill,
streak: lt.bestStreak ?? p.streak
};
}
function Leaderboard({ setRoute }) {
const [scope, setScope] = useState('wipe');
const [sort, setSort] = useState('kills');
const [query, setQuery] = useState('');
const [visible, setVisible] = useState(25);
const scoped = useMemo(() => {
return np.players.map(p => {
const proj = scope === 'all' ? projectAllTime(p) : p;
return { ...proj, _scopedKills: scopedKills(proj, scope) };
});
}, [scope]);
const filtered = useMemo(() => {
let list = scoped.slice();
if (query) list = list.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
(p.clan && p.clan.toLowerCase().includes(query.toLowerCase()))
);
if (sort === 'kills') {
list.sort((a, b) => b._scopedKills - a._scopedKills);
} else {
list.sort((a, b) => b[sort] - a[sort]);
}
return list;
}, [scoped, sort, query]);
const top3 = filtered.slice(0, 3);
const rest = filtered.slice(3, visible);
const showScopedKills = scope === '24h' || scope === '7d';
return (
{(() => { if (scope === 'all') return 'ALL TIME // ACROSS WIPES'; if (scope === '24h') return 'LAST 24 HOURS'; if (scope === '7d') return 'LAST 7 DAYS'; const ws = np.meta && np.meta.wipe_started; if (!ws) return 'CURRENT WIPE'; const days = Math.max(1, Math.floor((Date.now() - ws * 1000) / 86400000)); return `CURRENT WIPE // DAY ${days}`; })()}
Top of the Trash Heap
Live leaderboard for the Noobs Paradise 3X TRIO server. Click any operator to drill into their kill feed, weapons, and play patterns.
TOTAL KILLS{fmt(np.players.reduce((s, p) => s + p.kills, 0))}
RESOURCES{fmt(np.players.reduce((s, p) => s + p.resources, 0))}
ACTIVE{np.players.filter(p => p.online).length}
SCOPE
{[['wipe', 'Current Wipe'], ['24h', 'Last 24h'], ['7d', 'Last 7 Days'], ['all', 'All Time']].map(([k, l]) => (
))}
SORT
{[['kills', 'Kills'], ['resources', 'Resources'], ['kd', 'K/D'], ['playtime', 'Playtime'], ['damageDealt', 'Damage']].map(([k, l]) => (
))}
$
setQuery(e.target.value)} />
setRoute({ name: 'player', id: p.id })} killsKey={showScopedKills ? '_scopedKills' : 'kills'} />
RANK
OPERATOR
K / D
KILLS
RESOURCES
PLAYTIME
HS%
STREAK
FAV
STATUS
{rest.map((p, idx) => (
))}
{visible < filtered.length && (
)}
);
}
// ---------------- PLAYER OVERVIEW ----------------
function Heatmap({ data }) {
// Polar/radial 24x7 — 7 concentric rings (days), 24 wedges (hours)
const size = 360;
const cx = size / 2, cy = size / 2;
const innerR = 50;
const outerR = 168;
const ringW = (outerR - innerR) / 7;
const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
const wedge = (d, h) => {
const r0 = innerR + d * ringW;
const r1 = r0 + ringW - 1.4;
const a0 = (h / 24) * Math.PI * 2 - Math.PI / 2;
const a1 = ((h + 1) / 24) * Math.PI * 2 - Math.PI / 2;
const x0 = cx + r0 * Math.cos(a0), y0 = cy + r0 * Math.sin(a0);
const x1 = cx + r1 * Math.cos(a0), y1 = cy + r1 * Math.sin(a0);
const x2 = cx + r1 * Math.cos(a1), y2 = cy + r1 * Math.sin(a1);
const x3 = cx + r0 * Math.cos(a1), y3 = cy + r0 * Math.sin(a1);
return `M${x0},${y0} L${x1},${y1} A${r1},${r1} 0 0 1 ${x2},${y2} L${x3},${y3} A${r0},${r0} 0 0 0 ${x0},${y0} Z`;
};
const [hover, setHover] = useState(null);
const peak = useMemo(() => {
let best = { v: 0 };
data.forEach((row, d) => row.forEach((v, h) => { if (v > best.v) best = { v, d, h }; }));
return best;
}, [data]);
return (
LESS
{[0.1, 0.3, 0.5, 0.75, 1].map((o, i) => (
))}
MORE
{hover && (
{days[hover.d]} {String(hover.h).padStart(2, '0')}:00 — {hover.v}% active
)}
);
}
function fmtAgo(sec) {
if (sec < 5) return 'NOW';
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm';
if (sec < 86400) return Math.floor(sec / 3600) + 'h';
return Math.floor(sec / 86400) + 'd';
}
// `entries` is an array of killfeed entries from np.killfeed.
// Optional `viewerSteamId` filters to only kills involving that player and
// renders the verb relative to them ("killed" vs "killed by").
function KillFeed({ entries, viewerSteamId, title }) {
const [paused, setPaused] = useState(false);
const [tick, setTick] = useState(0);
useEffect(() => {
if (paused) return;
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, [paused]);
const list = useMemo(() => {
let xs = (entries || []).slice();
if (viewerSteamId) {
xs = xs.filter(e => e.killerId === viewerSteamId || e.victimId === viewerSteamId);
}
return xs.slice(0, 14);
}, [entries, viewerSteamId]);
const nowSec = Math.floor(Date.now() / 1000);
return (
{title || 'LIVE KILL FEED'}
{list.length === 0 ? (
{viewerSteamId ? 'No kills tracked for this player yet.' : 'No kills recorded yet.'}
) : (
{list.map((entry) => {
const viewerIsKiller = viewerSteamId ? entry.killerId === viewerSteamId : true;
const actor = viewerIsKiller ? (entry.killer || '—') : (entry.victim || '—');
const target = viewerIsKiller ? (entry.victim || '—') : (entry.killer || '—');
const ago = Math.max(0, nowSec - (entry.time || nowSec));
return (
-
{actor}
{entry.distance > 0 && {entry.distance}m}
{entry.headshot && HS}
{entry.wounded && WND}
{target}
{fmtAgo(ago)}
);
})}
)}
);
}
function SessionsChart({ data }) {
const sessions = data || [];
const maxMin = Math.max(...sessions.map(s => s.minutes || 0), 1);
const totalMin = sessions.reduce((a, b) => a + (b.minutes || 0), 0);
const totalKills = sessions.reduce((a, b) => a + (b.kills || 0), 0);
const totalDeaths = sessions.reduce((a, b) => a + (b.deaths || 0), 0);
return (
14-DAY MIN{totalMin}
14-DAY KILLS{totalKills}
14-DAY DEATHS{totalDeaths}
{sessions.map((s, i) => {
const h = Math.max(2, Math.round((s.minutes / maxMin) * 64));
const k = s.kills || 0;
const d = s.deaths || 0;
const isToday = i === sessions.length - 1;
const dayLabel = i === sessions.length - 1 ? 'TODAY' : (sessions.length - 1 - i) + 'd';
return (
{k || ''}
{d || ''}
{dayLabel}
);
})}
);
}
function WeaponBreakdown({ weapons }) {
const max = Math.max(...weapons.map(w => w.kills));
return (
);
}
function ResourceTotals({ resources }) {
const items = [
{ k: 'stone', label: 'Stone', color: '#9da69e' },
{ k: 'wood', label: 'Wood', color: '#a07a4a' },
{ k: 'metal', label: 'Metal Frags', color: '#7c8a92' },
{ k: 'sulfur', label: 'Sulfur', color: '#f4b74a' },
{ k: 'hqm', label: 'HQ Metal', color: '#e8eef0' },
{ k: 'cloth', label: 'Cloth', color: '#d6c5a8' },
{ k: 'scrap', label: 'Scrap', color: '#bce505' },
{ k: 'lowGrade', label: 'Low Grade', color: '#39c8b7' },
];
const total = Object.values(resources).reduce((s, v) => s + v, 0);
return (
TOTAL GATHERED
{fmt(total)}
UNITS
{items.map(i => (
{i.label}
{fmt(resources[i.k])}
))}
);
}
function AchievementWall({ achievements }) {
const earnedCount = achievements.filter(a => a.earned).length;
return (
UNLOCKED
{earnedCount} / {achievements.length}
{achievements.map(a => (
{a.earned ? ✓ : ?}
{a.name}
{a.desc}
{a.rarity.toUpperCase()}
))}
);
}
function PlayerOverview({ playerId, setRoute }) {
const player = np.players.find(p => p.id === playerId) || np.players[0];
if (!player) {
return (
);
}
const weapons = player.weapons || [];
const resources = player.resourceBreakdown || { stone: 0, wood: 0, metal: 0, sulfur: 0, hqm: 0, cloth: 0, scrap: 0, lowGrade: 0 };
const achievements = player.achievements || [];
const heatmap = player.heatmap || Array.from({ length: 7 }, () => new Array(24).fill(0));
const lastSeenStr = player.lastSeen || (player.online ? 'ONLINE NOW' : '—');
const totalWeaponKills = weapons.reduce((s, w) => s + (w.kills || 0), 0);
return (
{player.name.slice(0, 2).toUpperCase()}
RANK #{player.rank} · {player.clan ? `[${player.clan}]` : 'NO CLAN'}{player.country ? ` · ${player.country}` : ''}
{player.name}
{player.bio &&
{player.bio}
}
STEAM64{player.steamId}
JOINED{player.joined}
LAST SEEN{lastSeenStr}
KILLS{fmt(player.kills)}
DEATHS{fmt(player.deaths)}
K/D{player.kd}
PLAYTIME{player.playtime}h
HEADSHOT{player.headshotPct}%
LONGEST{player.longestKill}m
RAIDS{player.basesRaided}
STREAK= 5 ? 'live' : ''}>🔥 {player.streak}
ACTIVE PLAYTIME RADAR
24×7 RADIAL · UTC
WEAPON BREAKDOWN
{totalWeaponKills} KILLS RECORDED
{weapons.length > 0
?
:
No weapon kills tracked yet.
}
RESOURCES GATHERED
CURRENT WIPE
BADGE WALL
SERVER ACHIEVEMENTS
SESSION HISTORY
14-DAY · MINUTES PLAYED
);
}
// ---------------- App shell ----------------
function LoadingScreen({ message, error }) {
return (
{error ? 'CONNECTION FAULT' : 'LINKING TO SERVER'}
{error || (message || 'Polling stats endpoint…')}
);
}
function EmptyServer({ setRoute, route }) {
return (
SERVER QUIET
No tracked players yet. Stats will appear here once players start fighting.
);
}
function App() {
const [, forceTick] = useState(0);
const [ready, setReady] = useState(window.NP_DATA_READY === true && !!window.NP_DATA);
const [error, setError] = useState(window.NP_DATA_ERROR);
const [route, setRoute] = useState({ name: 'leaderboard' });
useEffect(() => {
function onReady(e) {
np = e.detail || window.NP_DATA;
setError(null);
setReady(true);
forceTick(t => t + 1);
}
function onError(e) {
setError(e.detail || 'Failed to load stats');
}
if (window.NP_DATA_READY && window.NP_DATA) {
np = window.NP_DATA;
setReady(true);
}
window.addEventListener('np-data-ready', onReady);
window.addEventListener('np-data-error', onError);
return () => {
window.removeEventListener('np-data-ready', onReady);
window.removeEventListener('np-data-error', onError);
};
}, []);
if (!ready || !np) return ;
const playerCount = (np.players && np.players.length) || 0;
if (playerCount === 0) return ;
const meta = np.meta || {};
const wipeStarted = meta.wipe_started ? new Date(meta.wipe_started * 1000) : null;
const wipeDays = wipeStarted ? Math.max(1, Math.floor((Date.now() - wipeStarted.getTime()) / 86400000)) : null;
return (
{route.name === 'leaderboard'
?
:
}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();