Emberveil source

website/highscores.js

(() => {
  const skillNames = ['Attack','Defence','Strength','Hitpoints','Ranged','Prayer','Magic','Cooking','Woodcutting','Fletching','Fishing','Firemaking','Crafting','Smithing','Mining','Herblore','Agility','Thieving','Slayer','Farming','Runecrafting'];
  const skill = document.getElementById('score-skill');
  if (!skill) return;
  const search = document.getElementById('score-search'), rows = document.getElementById('score-rows');
  const status = document.getElementById('score-status'), arrivals = document.getElementById('score-new');
  const prev = document.getElementById('score-prev'), next = document.getElementById('score-next');
  let data = null, page = 0, busy = false, selectedPlayer = null;
  const menu = document.getElementById('score-skill-menu');
  for (const [value, name] of [['overall','Overall'],...skillNames.map((name,i) => [String(i),name])]) {
    const button=document.createElement('button'); button.type='button'; button.textContent=name;
    button.setAttribute('aria-pressed',String(value==='overall')); button.dataset.skill=value;
    button.addEventListener('click',() => {skill.value=value;page=0;render();}); menu.append(button);
  }
  for (const [i, name] of skillNames.entries()) { const option = new Option(name, String(i)); skill.add(option); }
  function render() {
    if (!data) return;
    const overall = skill.value === 'overall', index = Number(skill.value);
    document.getElementById('score-board-title').textContent=overall ? 'Overall' : skillNames[index];
    for (const button of menu.children) button.setAttribute('aria-pressed',String(button.dataset.skill===skill.value));
    const stats = p => overall ? {level:p.total_level, xp:p.total_xp} : p.skills[index];
    const ranked = [...data.players].sort((a,b) => (overall ? b.total_level-a.total_level : 0) || stats(b).xp-stats(a).xp || a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
    const query = search.value.trim().toLowerCase();
    const found = ranked.map((p,i) => ({p,rank:i+1})).filter(({p}) => p.name.toLowerCase().includes(query));
    page = Math.min(page, Math.max(0, Math.ceil(found.length/20)-1));
    rows.replaceChildren();
    document.getElementById('score-level-heading').textContent = overall ? 'Total level' : 'Level';
    for (const {p,rank} of found.slice(page*20,page*20+20)) {
      const row = document.createElement('tr');
      for (const value of [rank.toLocaleString(),p.name,stats(p).level.toLocaleString(),stats(p).xp.toLocaleString()]) { const cell = document.createElement('td'); cell.textContent = value; row.append(cell); }
      row.children[1].textContent='';
      const playerButton=document.createElement('button'); playerButton.type='button'; playerButton.className='score-player-link'; playerButton.textContent=p.name;
      playerButton.addEventListener('click',() => {selectedPlayer=p.name;renderProfile();}); row.children[1].append(playerButton);
      if (p.role !== 'Player') { const coOwner=p.role!=='Player'&&p.name.toLowerCase()==='darren'; const role=document.createElement('span'); role.className='score-role'+(coOwner?' score-role-coowner':p.role==='Owner'?' score-role-owner':''); role.textContent=coOwner?'Co-owner':p.role; row.children[1].append(role); }
      rows.append(row);
    }
    if (!found.length) { const row=document.createElement('tr'), cell=document.createElement('td'); cell.colSpan=4; cell.textContent=query ? 'No matching players.' : 'No saved players yet.'; row.append(cell); rows.append(row); }
    document.getElementById('score-page').textContent=found.length ? `${page*20+1}–${Math.min(found.length,page*20+20)} of ${found.length}` : '0 players';
    prev.disabled=page===0; next.disabled=(page+1)*20>=found.length;
    arrivals.replaceChildren();
    for (const player of data.recent_players) { const li=document.createElement('li'), time=document.createElement('time'); li.textContent=player.name; time.dateTime=player.joined_at; time.textContent='Added '+new Date(player.joined_at).toLocaleDateString(); li.append(time); arrivals.append(li); }
    if (!arrivals.children.length) { const li=document.createElement('li'); li.textContent='The next new adventurer will appear here after their first save.'; arrivals.append(li); }
    renderProfile();
  }
  function renderProfile() {
    const profile=document.getElementById('score-profile'), player=data.players.find(p => p.name===selectedPlayer);
    profile.replaceChildren(); profile.hidden=!player; if (!player) return;
    const title=document.createElement('h3');title.textContent=player.name+' · Skill overview';profile.append(title);
    const close=document.createElement('button');close.textContent='Close player details';close.type='button';close.className='score-player-link';close.addEventListener('click',() => {selectedPlayer=null;renderProfile();});profile.append(close);
    const list=document.createElement('dl');list.className='score-profile-grid';
    for (const s of player.skills) {const block=document.createElement('div'),name=document.createElement('dt'),value=document.createElement('dd');name.textContent=s.name;value.textContent=`Level ${s.level} · ${s.xp.toLocaleString()} XP`;block.append(name);block.append(value);list.append(block);}profile.append(list);
  }
  function valid(payload) {
    return payload && Number.isFinite(Date.parse(payload.updated_at)) && Array.isArray(payload.players) && Array.isArray(payload.recent_players) && payload.players.every(p => /^[A-Za-z0-9 _-]{1,12}$/.test(p.name) && ['Owner','Staff','Player'].includes(p.role) && Number.isSafeInteger(p.total_level) && Number.isSafeInteger(p.total_xp) && Array.isArray(p.skills) && p.skills.length===21 && p.skills.every((s,i) => s.name===skillNames[i] && Number.isSafeInteger(s.level) && s.level>=1 && s.level<=99 && Number.isSafeInteger(s.xp) && s.xp>=0)) && payload.recent_players.every(p => /^[A-Za-z0-9 _-]{1,12}$/.test(p.name) && Number.isFinite(Date.parse(p.joined_at)));
  }
  async function refresh() {
    if (busy) return;
    busy=true;
    const controller=new AbortController(), timeout=setTimeout(() => controller.abort(),8000);
    try {
      const response=await fetch('https://downloads.emberveil.net/highscores.json',{cache:'no-store',signal:controller.signal});
      if (!response.ok) throw new Error('Unavailable');
      const payload=await response.json();
      if (!valid(payload)) throw new Error('Invalid rankings');
      data=payload; render();
      const age=Date.now()-Date.parse(data.updated_at);
      status.textContent=age>180000 || age< -45000 ? 'Showing saved rankings. Automatic updates are delayed.' : `${data.players.length.toLocaleString()} adventurers · Updated ${new Date(data.updated_at).toLocaleTimeString()} · Saved game stats`;
    } catch (_) { status.textContent=data ? 'Connection interrupted. Showing the last loaded rankings; retrying automatically.' : 'Rankings are temporarily unavailable. Retrying automatically.'; }
    finally { clearTimeout(timeout); busy=false; }
  }
  skill.addEventListener('change',() => {page=0;render();}); search.addEventListener('input',() => {page=0;render();});
  prev.addEventListener('click',() => {page--;render();}); next.addEventListener('click',() => {page++;render();});
  refresh(); setInterval(() => {if (!document.hidden) refresh();},30000);
  document.addEventListener('visibilitychange',() => {if (!document.hidden) refresh();});
})();