const API = '/recnet/api'; const app = document.querySelector('#app'); const sidenav = document.querySelector('#sidenav'); const navBackdrop = document.querySelector('#navBackdrop'); const navToggle = document.querySelector('#navToggle'); const topbarUser = document.querySelector('#topbarUser'); const envBadge = document.querySelector('#envBadge'); let currentUser = null; const esc = s => String(s ?? '').replace(/[&<>'"]/g, c => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[c])); function fetchJson(url, init = {}) { return fetch(url, { ...init, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(init.headers || {}) } }); } async function get(url) { const r = await fetchJson(url); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Request failed (${r.status})`); } return r.json(); } async function send(url, method, body) { const r = await fetchJson(url, { method, body: body !== undefined ? JSON.stringify(body) : undefined }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.error || `Request failed (${r.status})`); return data; } function openNav() { sidenav.classList.add('open'); navBackdrop.classList.add('open'); } function closeNav() { sidenav.classList.remove('open'); navBackdrop.classList.remove('open'); } navToggle.addEventListener('click', () => sidenav.classList.contains('open') ? closeNav() : openNav()); navBackdrop.addEventListener('click', closeNav); function applyRoleGating() { const isDev = currentUser?.isDeveloper === true; document.querySelectorAll('[data-requires="developer"]').forEach(el => { el.style.display = isDev ? '' : 'none'; }); document.querySelectorAll('.dev-only').forEach(el => { el.style.display = isDev ? 'flex' : 'none'; }); document.querySelectorAll('.dev-block').forEach(el => { el.style.display = isDev ? 'block' : 'none'; }); topbarUser.textContent = currentUser ? `${currentUser.displayName || currentUser.username} · ${currentUser.isDeveloper ? 'Developer' : currentUser.isModerator ? 'Moderator' : 'Staff'}` : ''; envBadge.textContent = currentUser?.isDeveloper ? 'DEV ACCESS' : 'PROD'; envBadge.className = 'env-badge' + (currentUser?.isDeveloper ? ' dev' : ''); } function renderLoginGate() { app.innerHTML = `

Staff sign-in required

Log in with a Moderator or Developer account through the main site, then come back here.

Go to main site
`; } function setActiveNav(view) { document.querySelectorAll('.nav-link').forEach(link => link.classList.toggle('active', link.dataset.view === view)); } const DEVELOPER_ONLY_VIEWS = new Set([ 'community-board', 'coach-gifting', 'announcements', 'player-events', 'room-importer', 'logs', 'scanner-logs', 'anticheat-logs', 'config', 'account-creation', 'avatar-items', 'ip-bans', 'steam-blacklist', 'shop' ]); function renderDeveloperOnlyPlaceholder(view) { const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent || 'This section'; app.innerHTML = `

${esc(title)} needs Developer access

This section's actions all require the Developer role on the server, not just Moderator.

`; } async function route() { closeNav(); const hash = location.hash.slice(1); const [view, ...rest] = hash.split('/'); setActiveNav(view || 'players'); if (DEVELOPER_ONLY_VIEWS.has(view) && currentUser?.isDeveloper !== true) { renderDeveloperOnlyPlaceholder(view); return; } try { if (view === 'players' && rest[0]) await playerDetail(rest[0]); else if (view === 'players' || view === '') await playersList(); else if (view === 'player-reports') await playerReportsView(); else if (view === 'bug-reports') await bugReportsView(); else if (view === 'rooms' && rest[0]) await roomDetail(rest[0]); else if (view === 'rooms') await roomsList(); else if (view === 'instances') await liveInstances(); else if (view === 'age-verification') await ageVerificationView(); else if (view === 'announcements') await announcementsView(); else if (view === 'player-events') await playerEventsView(); else if (view === 'config') await configView(); else if (view === 'account-creation') await accountCreationView(); else if (view === 'avatar-items') await avatarItemsView(); else if (view === 'room-importer') await roomImporterView(); else if (view === 'logs') await logsView(); else if (view === 'scanner-logs') await scannerLogsView(); else if (view === 'anticheat-logs') await anticheatLogsView(); else if (view === 'ip-bans') await ipBansView(); else if (view === 'community-board') await communityBoardView(); else if (view === 'clubs' && rest[0]) await clubDetail(rest[0]); else if (view === 'clubs') await clubsView(); else if (view === 'coach-gifting') await coachGiftingView(); else if (view === 'steam-blacklist') await steamBlacklistView(); else if (view === 'shop') await shopView(); else if (view === 'preferences') await preferencesView(); else await placeholder(view); } catch (error) { app.innerHTML = `

${esc(error.message)}

`; } } addEventListener('hashchange', route); function placeholder(view) { const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent.replace(' · soon', '') || 'This section'; app.innerHTML = `

${esc(title)} isn't built here yet

This part of the new panel doesn't exist yet - it still works fine in the original admin panel for now.

`; } async function playersList() { app.innerHTML = `

Players

Loading overview
`; (async () => { const overview = document.querySelector('#playerOverview'); try { const stats = await get(`${API}/admin/overview`); const entries = [ ['Accounts', stats.accounts], ['Admins', stats.admins], ['Mod Locks', stats.moderationLocks], ['Verified', stats.verified], ['RR+', stats.rrPlus], ['Rooms', stats.rooms], ['Photos', stats.photos], ['Cheers', stats.cheers], ['Comments', stats.comments] ]; overview.innerHTML = entries.map(([label, value]) => `
${value}${esc(label)}
`).join(''); } catch (error) { overview.innerHTML = `

${esc(error.message)}

`; } })(); const input = document.querySelector('#playerSearch'), results = document.querySelector('#playerListResults'); async function runSearch() { results.innerHTML = '
Loading players
'; try { const players = await get(`${API}/admin/accounts?search=${encodeURIComponent(input.value.trim())}`); if (!players.length) { results.innerHTML = '

No players found.

'; return; } results.innerHTML = players.map(p => `
${esc(p.displayName || p.username)}
@${esc(p.username)} · ID ${p.accountId} · Level ${p.level}${p.ban ? ' · Banned' : ''}
`).join(''); } catch (error) { results.innerHTML = `

${esc(error.message)}

`; } } const playerSearchGo = document.querySelector('#playerSearchGo'); if (playerSearchGo) playerSearchGo.addEventListener('click', runSearch); if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(); }); await runSearch(); } const ROLE_BADGE_CLASS = { Developer: 'dev', Moderator: 'mod' }; async function playerDetail(accountIdRaw) { const accountId = Number(accountIdRaw); app.innerHTML = '
Loading player
'; let player; try { player = await get(`${API}/admin/accounts/${accountId}`); } catch (error) { app.innerHTML = `← Back to Players

${esc(error.message)}

`; return; } const joined = player.createdAt ? new Date(player.createdAt).toLocaleDateString() : 'Unknown'; const badges = [...player.roles.map(r => `${esc(r)}`)]; if (player.isJunior) badges.push('Junior'); if (player.ban) badges.push('Banned'); app.innerHTML = ` ← Back to Players
@${esc(player.username)}
${esc(player.displayName || '')}
Level: ${player.level}
Bio: ${esc(player.bio || '(none)')}
ID: ${player.accountId} · Joined ${joined}
${badges.join('') || 'No roles'}${player.moderationLock ? ' Mod Locked' : ''}
Settings
Relationships
Threads
Inventory
My Photos
Photos of Me
Gifts
Reputation
Progression
Troll
`; document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab)); renderPlayerTab(tab.dataset.tab, accountId); })); renderPlayerTab('settings', accountId); applyRoleGating(); const msgBtn = document.querySelector('[data-act="message"]'); if (msgBtn) msgBtn.addEventListener('click', async () => { const message = prompt('Message to send as Coach:'); if (!message) return; try { await send(`${API}/admin/accounts/${accountId}/message`, 'POST', { message }); alert('Sent.'); } catch (error) { alert(error.message); } }); const forceJoinBtn = document.querySelector('[data-act="force-join"]'); if (forceJoinBtn) forceJoinBtn.addEventListener('click', async () => { try { const r = await send(`${API}/admin/accounts/${accountId}/force-join-instance`, 'POST', {}); alert(r.delivered ? `Pushed live into "${r.roomName}".` : `Their record now points at "${r.roomName}" - it'll surface next time they connect.`); } catch (error) { alert(error.message); } }); const resetUserBtn = document.querySelector('[data-act="reset-username"]'); if (resetUserBtn) resetUserBtn.addEventListener('click', async () => { if (!confirm('Reset this account\'s username to a randomly generated one?')) return; try { const r = await send(`${API}/admin/accounts/${accountId}/username/reset`, 'POST', {}); alert(`Username reset from "${r.previousUsername}" to "${r.username}".`); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const editProfileBtn = document.querySelector('[data-act="edit-profile"]'); if (editProfileBtn) editProfileBtn.addEventListener('click', async () => { const newUsername = prompt('New username (3-20 letters, numbers, underscores):', player.username); if (!newUsername) return; const newDisplayName = prompt('New display name (1-32 characters):', player.displayName || ''); if (newDisplayName === null) return; const bio = prompt('Bio (up to 500 characters):', player.bio || ''); if (bio === null) return; const email = prompt('Email:', player.email || ''); if (email === null) return; try { await send(`${API}/admin/accounts/${accountId}/profile`, 'PUT', { username: newUsername, displayName: newDisplayName, bio, email }); alert('Profile updated successfully.'); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const resetPfpBtn = document.querySelector('[data-act="reset-pfp"]'); if (resetPfpBtn) resetPfpBtn.addEventListener('click', async () => { if (!confirm('Reset this account\'s profile picture to the default?')) return; try { await send(`${API}/admin/accounts/${accountId}/images/pfp/reset`, 'POST', {}); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const resetBannerBtn = document.querySelector('[data-act="reset-banner"]'); if (resetBannerBtn) resetBannerBtn.addEventListener('click', async () => { if (!confirm('Reset this account\'s banner image?')) return; try { await send(`${API}/admin/accounts/${accountId}/images/banner/reset`, 'POST', {}); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const resetPasswordBtn = document.querySelector('[data-act="reset-password"]'); if (resetPasswordBtn) resetPasswordBtn.addEventListener('click', async () => { const newPassword = prompt('New password (minimum 8 characters):'); if (!newPassword) return; try { await send(`${API}/admin/accounts/${accountId}/password`, 'PUT', { newPassword }); alert('Password reset.'); } catch (error) { alert(error.message); } }); const copyIdBtn = document.querySelector('[data-act="copy-id"]'); if (copyIdBtn) copyIdBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(String(accountId)); copyIdBtn.textContent = 'Copied!'; setTimeout(() => copyIdBtn.textContent = 'Copy ID', 1200); } catch (error) { alert('Could not copy to clipboard.'); } }); const modLockBtn = document.querySelector('[data-act="mod-lock"]'); if (modLockBtn) modLockBtn.addEventListener('click', async () => { if (player.moderationLock) { const removeLinked = player.moderationLock.isRelated ? confirm('Also remove the Moderation Lock from linked accounts?') : false; if (!confirm('Remove this account\'s Moderation Lock?')) return; try { await send(`${API}/admin/accounts/${accountId}/moderation-lock`, 'DELETE', { removeLinkedAccounts: removeLinked }); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } return; } if (!confirm('Apply a permanent Moderation Lock to this account?')) return; try { await send(`${API}/admin/accounts/${accountId}/moderation-lock`, 'POST', {}); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const deleteAccountBtn = document.querySelector('[data-act="delete-account"]'); if (deleteAccountBtn) deleteAccountBtn.addEventListener('click', async () => { const confirmation = prompt(`This permanently deletes the account. Type DELETE ${accountId} to confirm:`); if (!confirmation) return; try { await send(`${API}/admin/accounts/${accountId}`, 'DELETE', { confirmation }); alert('Account deleted.'); location.hash = '#players'; } catch (error) { alert(error.message); } }); const addRoleBtn = document.querySelector('[data-act="add-role"]'); if (addRoleBtn) addRoleBtn.addEventListener('click', async () => { const roleSelect = document.querySelector('#roleSelect'); const role = roleSelect ? roleSelect.value : prompt('Role to add (e.g. Moderator, Developer, RRPlus):'); if (!role) return; try { await send(`${API}/admin/accounts/${accountId}/roles`, 'POST', { role, enabled: true }); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const clearRolesBtn = document.querySelector('[data-act="clear-roles"]'); if (clearRolesBtn) clearRolesBtn.addEventListener('click', async () => { if (!confirm('Clear all roles from this account?')) return; try { await send(`${API}/admin/accounts/${accountId}/roles`, 'DELETE'); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); const forceIntoArbitraryBtn = document.querySelector('[data-act="force-into-arbitrary"]'); if (forceIntoArbitraryBtn) forceIntoArbitraryBtn.addEventListener('click', async () => { const targetAccountId = prompt('Account ID of the player whose live instance to force this player into:'); if (!targetAccountId) return; try { const r = await send(`${API}/admin/accounts/${accountId}/force-into/${Number(targetAccountId)}`, 'POST', {}); alert(r.delivered ? `Forced into "${r.roomName}" live.` : `Record updated to "${r.roomName}" - will apply on next connection.`); } catch (error) { alert(error.message); } }); const banBtn = document.querySelector('[data-act="ban"]'); if (banBtn) banBtn.addEventListener('click', async () => { if (player.ban) { if (!confirm('Unban this account?')) return; try { await send(`${API}/admin/accounts/${accountId}/ban`, 'DELETE'); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } return; } const reason = prompt('Ban reason:'); if (!reason) return; const durationAmount = Number(prompt('Duration amount (ignored if permanent):', '1') || '1'); const durationUnit = prompt('Duration unit (seconds/minutes/hours/days/weeks/permanent):', 'days') || 'days'; try { await send(`${API}/admin/accounts/${accountId}/ban`, 'POST', { reason, linkBan: false, durationAmount, durationUnit }); await playerDetail(accountIdRaw); } catch (error) { alert(error.message); } }); } async function renderPlayerTab(tab, accountId) { const body = document.querySelector('#tabBody'); if (tab === 'settings') { body.innerHTML = `

Settings

Token Balance

Loading...

Linked Platforms

Loading platforms

Custom Settings

Loading settings
`; (async () => { const balanceCurrent = document.querySelector('#balanceCurrent'); try { const account = await get(`${API}/admin/accounts/${accountId}`); balanceCurrent.textContent = `Current balance: ${account.balance} tokens.`; renderPlatforms(account.platforms); } catch (error) { balanceCurrent.textContent = error.message; balanceCurrent.classList.add('error'); } })(); function renderPlatforms(platforms) { const list = document.querySelector('#platformList'); if (!platforms || !platforms.length) { list.innerHTML = '

No linked platforms.

'; return; } list.innerHTML = platforms.map(p => `
${esc(p.platform)}
${esc(p.platformId)}
`).join(''); list.querySelectorAll('.unlink-platform').forEach(btn => btn.addEventListener('click', async () => { if (!confirm(`Unlink ${btn.dataset.platform} identity ${btn.dataset.platformId}?`)) return; try { const r = await send(`${API}/admin/accounts/${accountId}/platforms`, 'POST', { platform: btn.dataset.platform, platformId: btn.dataset.platformId, enabled: false }); renderPlatforms(r.platforms); } catch (error) { alert(error.message); } })); } const applyBalanceBtn = document.querySelector('#applyBalance'); if (applyBalanceBtn) applyBalanceBtn.addEventListener('click', async () => { const status = document.querySelector('#balanceStatus'); const amount = Number(document.querySelector('#balanceAmount').value); const add = document.querySelector('#balanceMode').value === 'add'; if (amount < 0) { status.textContent = 'Amount cannot be negative.'; status.classList.add('error'); return; } try { const r = await send(`${API}/admin/accounts/${accountId}/balance`, 'POST', { amount, add }); status.textContent = `Balance is now ${r.balance} tokens.`; status.classList.remove('error'); document.querySelector('#balanceCurrent').textContent = `Current balance: ${r.balance} tokens.`; } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const addPlatformBtn = document.querySelector('#addPlatform'); if (addPlatformBtn) addPlatformBtn.addEventListener('click', async () => { const status = document.querySelector('#platformStatus'); const platform = document.querySelector('#newPlatform').value; const platformId = document.querySelector('#newPlatformId').value.trim(); if (!platformId) { status.textContent = 'Enter a platform ID.'; status.classList.add('error'); return; } try { const r = await send(`${API}/admin/accounts/${accountId}/platforms`, 'POST', { platform, platformId, enabled: true }); status.textContent = 'Linked.'; status.classList.remove('error'); renderPlatforms(r.platforms); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); async function loadSettings() { const table = document.querySelector('#settingsTable'); try { const settings = await get(`${API}/admin/accounts/${accountId}/settings`); if (!settings.length) { table.innerHTML = '

No settings stored.

'; return; } table.innerHTML = `${settings.map(s => ``).join('')}
KeyValueActions
${esc(s.key)}
`; table.querySelectorAll('.save-setting').forEach(btn => btn.addEventListener('click', async () => { const row = btn.closest('tr'), key = row.dataset.key; const settingValue = row.querySelector('.setting-value'); if (!settingValue) return; const value = settingValue.value; try { await send(`${API}/admin/accounts/${accountId}/settings`, 'PUT', { key, value }); } catch (error) { alert(error.message); } })); table.querySelectorAll('.delete-setting').forEach(btn => btn.addEventListener('click', async () => { const row = btn.closest('tr'), key = row.dataset.key; if (!confirm(`Delete "${key}"?`)) return; try { await send(`${API}/admin/accounts/${accountId}/settings/${encodeURIComponent(key)}`, 'DELETE'); row.remove(); } catch (error) { alert(error.message); } })); } catch (error) { table.innerHTML = `

${esc(error.message)}

`; } } const addSettingBtn = document.querySelector('#addSetting'); if (addSettingBtn) addSettingBtn.addEventListener('click', async () => { const newSettingKey = document.querySelector('#newSettingKey'); const newSettingValue = document.querySelector('#newSettingValue'); if (!newSettingKey || !newSettingValue) return; const key = newSettingKey.value.trim(), value = newSettingValue.value; if (!key) return; try { await send(`${API}/admin/accounts/${accountId}/settings`, 'PUT', { key, value }); newSettingKey.value = ''; newSettingValue.value = ''; await loadSettings(); } catch (error) { alert(error.message); } }); await loadSettings(); } else if (tab === 'progression') { body.innerHTML = `

Progression & Account State

Update Progression

Account state & appearance

`; let account; try { account = await get(`${API}/admin/accounts/${accountId}`); document.querySelector('#progLevel').value = account.level; document.querySelector('#progXP').value = account.xp; document.querySelector('#stateUsernameChanges').value = account.availableUsernameChanges ?? 0; document.querySelector('#statePronouns').value = account.personalPronouns ?? 0; document.querySelector('#stateEmoji').value = account.displayEmoji || ''; document.querySelector('#stateProfileImage').value = account.profileImagePath || ''; document.querySelector('#stateBannerImage').value = account.bannerImagePath || ''; document.querySelector('#stateIsJunior').checked = !!account.isJunior; } catch (error) { body.innerHTML = `

${esc(error.message)}

`; return; } function detailsPayload(overrides) { return { level: account.level, xp: account.xp, isJunior: document.querySelector('#stateIsJunior').checked, availableUsernameChanges: parseInt(document.querySelector('#stateUsernameChanges').value) || 0, displayEmoji: document.querySelector('#stateEmoji').value, personalPronouns: parseInt(document.querySelector('#statePronouns').value) || 0, profileImage: document.querySelector('#stateProfileImage').value, bannerImage: document.querySelector('#stateBannerImage').value, ...overrides }; } const updateProgBtn = document.querySelector('#updateProgression'); if (updateProgBtn) updateProgBtn.addEventListener('click', async () => { const status = document.querySelector('#progStatus'); const level = parseInt(document.querySelector('#progLevel').value); const xp = parseInt(document.querySelector('#progXP').value); if (level < 1 || level > 50) { status.textContent = 'Level must be 1-50.'; status.classList.add('error'); return; } if (xp < 0) { status.textContent = 'XP cannot be negative.'; status.classList.add('error'); return; } try { await send(`${API}/admin/accounts/${accountId}/details`, 'PUT', detailsPayload({ level, xp })); account.level = level; account.xp = xp; status.textContent = 'Updated.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const updateStateBtn = document.querySelector('#updateAccountState'); if (updateStateBtn) updateStateBtn.addEventListener('click', async () => { const status = document.querySelector('#stateStatus'); try { await send(`${API}/admin/accounts/${accountId}/details`, 'PUT', detailsPayload({})); status.textContent = 'Saved.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); } else if (tab === 'relationships') { body.innerHTML = `

Relationships

Force add friend

Loading relationships
`; applyRoleGating(); async function loadRelationships() { const list = document.querySelector('#relationshipsList'); list.innerHTML = '
Loading relationships
'; try { const relationships = await get(`${API}/admin/accounts/${accountId}/relationships`); if (!relationships.length) { list.innerHTML = '

No relationships found.

'; return; } const isDev = currentUser?.isDeveloper === true; list.innerHTML = `${isDev ? '' : ''}${relationships.map(r => ` ${isDev ? `` : ''} `).join('')}
Player IDUsernameDisplay NameTypeFlagsActions
${r.playerId}${esc(r.username || 'N/A')}${esc(r.displayName || 'N/A')}${esc(r.relationshipType)}${r.favorited ? '⭐ ' : ''}${r.muted ? '🔇 ' : ''}${r.ignored ? '🚫' : ''}
`; list.querySelectorAll('.remove-relationship').forEach(btn => btn.addEventListener('click', async () => { if (!confirm('Remove this relationship?')) return; try { await send(`${API}/admin/accounts/${accountId}/relationships/friend/${btn.dataset.id}`, 'DELETE'); await loadRelationships(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } const forceAddFriendBtn = document.querySelector('#forceAddFriend'); if (forceAddFriendBtn) forceAddFriendBtn.addEventListener('click', async () => { const status = document.querySelector('#forceAddFriendStatus'); const targetAccountId = Number(document.querySelector('#forceAddFriendId').value); if (!targetAccountId) return; try { await send(`${API}/admin/accounts/${accountId}/relationships/friend`, 'POST', { targetAccountId }); status.textContent = 'Added.'; status.classList.remove('error'); await loadRelationships(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); await loadRelationships(); } else if (tab === 'threads') { body.innerHTML = `

Threads

Loading threads
`; const list = document.querySelector('#threadsList'); const messagesBox = document.querySelector('#threadMessages'); async function openThread(threadId, name) { messagesBox.innerHTML = `

${esc(name)}

Loading messages
`; try { const messages = await get(`${API}/admin/accounts/${accountId}/chat/threads/${threadId}/messages`); messagesBox.innerHTML = `

${esc(name)}

${messages.length ? messages.map(m => `
${esc(m.senderDisplayName)}
${esc(m.body)}
${new Date(m.createdAt).toLocaleString()}
`).join('') : '

No messages.

'}
`; } catch (error) { messagesBox.innerHTML = `

${esc(error.message)}

`; } } try { const threads = await get(`${API}/admin/accounts/${accountId}/chat/threads`); if (!threads.length) { list.innerHTML = '

No message threads.

'; return; } list.innerHTML = threads.map(t => { const name = t.name || t.members.map(m => m.displayName).join(', ') || `Thread ${t.threadId}`; return `
${esc(name)}
${t.lastMessage ? esc(t.lastMessage.body) : 'No messages yet'}
`; }).join(''); list.querySelectorAll('.room-row').forEach(row => row.addEventListener('click', () => openThread(row.dataset.id, row.dataset.name))); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } else if (tab === 'inventory') { body.innerHTML = `

Inventory

Loading inventory
`; const searchInput = document.querySelector('#inventorySearch'); const results = document.querySelector('#inventoryResults'); async function loadInventory() { const search = searchInput.value.trim(); results.innerHTML = '
Loading inventory
'; try { const data = await get(`${API}/admin/accounts/${accountId}/inventory?search=${encodeURIComponent(search)}`); const sections = []; if (data.avatarItems && data.avatarItems.length) { sections.push(`

Avatar Items

${data.avatarItems.map(item => `
${esc(item.friendlyName)}
ID: ${item.avatarItemId}
`).join('')}
`); } if (data.consumables && data.consumables.length) { sections.push(`

Consumables

${data.consumables.map(item => `
${esc(item.friendlyName)}
ID: ${item.consumableItemId}
`).join('')}
`); } if (!sections.length) { results.innerHTML = '

No items found.

'; return; } results.innerHTML = sections.join('

'); results.querySelectorAll('.inventory-check').forEach(chk => chk.addEventListener('change', async () => { const item = chk.closest('.inventory-item'); try { await send(`${API}/admin/accounts/${accountId}/inventory/avatar`, 'POST', { avatarItemId: Number(item.dataset.id), avatarItemDesc: item.dataset.desc, owned: chk.checked }); } catch (error) { alert(error.message); chk.checked = !chk.checked; } })); results.querySelectorAll('.inventory-quantity').forEach(input => input.addEventListener('change', async () => { const item = input.closest('.inventory-item'); const qty = Number(input.value); if (qty < 0 || qty > 100000) { alert('Quantity must be 0-100000'); input.value = item.dataset.prevQty || 0; return; } try { await send(`${API}/admin/accounts/${accountId}/inventory/consumable`, 'POST', { consumableItemId: Number(item.dataset.id), consumableItemDesc: item.dataset.desc, quantity: qty }); item.dataset.prevQty = qty; } catch (error) { alert(error.message); input.value = item.dataset.prevQty || 0; } })); } catch (error) { results.innerHTML = `

${esc(error.message)}

`; } } const searchInvBtn = document.querySelector('#searchInventory'); if (searchInvBtn) searchInvBtn.addEventListener('click', loadInventory); if (searchInput) searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') loadInventory(); }); await loadInventory(); } else if (tab === 'reputation') { body.innerHTML = `

Reputation / Cheer Badges

Loading reputation
`; const content = document.querySelector('#reputationContent'); async function loadReputation() { content.innerHTML = '
Loading reputation
'; try { const data = await get(`${API}/admin/accounts/${accountId}/reputation`); content.innerHTML = `




Cheer Counts







`; const saveRepBtn = document.querySelector('#saveReputation'); if (saveRepBtn) saveRepBtn.addEventListener('click', async () => { try { const status = document.querySelector('#reputationStatus'); const isCheerful = document.querySelector('#isCheerful'); const selectedCheer = document.querySelector('#selectedCheer'); const cheerGeneral = document.querySelector('#cheerGeneral'); const cheerHelpful = document.querySelector('#cheerHelpful'); const cheerSportsman = document.querySelector('#cheerSportsman'); const cheerGreatHost = document.querySelector('#cheerGreatHost'); const cheerCreative = document.querySelector('#cheerCreative'); if (!status || !isCheerful || !selectedCheer || !cheerGeneral || !cheerHelpful || !cheerSportsman || !cheerGreatHost || !cheerCreative) return; status.textContent = 'Saving...'; status.classList.remove('error'); await send(`${API}/admin/accounts/${accountId}/reputation`, 'PUT', { isCheerful: isCheerful.checked, selectedCheer: selectedCheer.value, cheerGeneral: Number(cheerGeneral.value), cheerHelpful: Number(cheerHelpful.value), cheerSportsman: Number(cheerSportsman.value), cheerGreatHost: Number(cheerGreatHost.value), cheerCreative: Number(cheerCreative.value) }); status.textContent = 'Saved.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); } catch (error) { content.innerHTML = `

${esc(error.message)}

`; } } await loadReputation(); } else if (tab === 'photos-mine' || tab === 'photos-of-me') { const type = tab === 'photos-mine' ? 'mine' : 'of-me'; body.innerHTML = `

${tab === 'photos-mine' ? 'My Photos' : 'Photos of Me'}

Loading photos
`; const content = document.querySelector('#photosContent'); async function loadPhotos() { content.innerHTML = '
Loading photos
'; try { const data = await get(`${API}/admin/accounts/${accountId}/photos?type=${type}`); if (!data.photos.length) { content.innerHTML = '

No photos found.

'; return; } content.innerHTML = `

${data.photoCount} photo(s)

${data.photos.map(p => `
${esc(p.path)}
${new Date(p.takenAt).toLocaleString()}
`).join('')}
`; } catch (error) { content.innerHTML = `

${esc(error.message)}

`; } } await loadPhotos(); } else if (tab === 'gifts') { const isDev = currentUser?.isDeveloper === true; body.innerHTML = `

Pending Gifts

${isDev ? '' : ''}
Loading pending gifts
`; const list = document.querySelector('#giftsList'); async function loadGifts() { list.innerHTML = '
Loading pending gifts
'; try { const gifts = await get(`${API}/admin/accounts/${accountId}/gifts`); list.innerHTML = gifts.length ? gifts.map(g => `
${g.thumbnailImage ? `` : ''}
${esc(g.friendlyName || (g.currency ? `${g.currency} tokens` : g.xp ? `${g.xp} XP` : 'Gift'))}
From ${esc(g.fromDisplayName)} · GiftContext ${g.giftContext}${g.consumableQuantity > 1 ? ` · x${g.consumableQuantity}` : ''} ${g.message ? `
"${esc(g.message)}"` : ''}
${isDev ? `` : ''}
`).join('') : '

No pending gifts.

'; list.querySelectorAll('.clear-gift').forEach(btn => btn.addEventListener('click', async () => { const row = btn.closest('[data-id]'); if (!confirm('Clear this pending gift? It cannot be recovered.')) return; try { await send(`${API}/admin/accounts/${accountId}/gifts/${row.dataset.id}`, 'DELETE'); await loadGifts(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } const clearAllBtn = document.querySelector('#clearAllGifts'); if (clearAllBtn) clearAllBtn.addEventListener('click', async () => { const status = document.querySelector('#giftsStatus'); if (!confirm('Clear every pending gift for this player? This cannot be recovered.')) return; try { const r = await send(`${API}/admin/accounts/${accountId}/gifts`, 'DELETE'); status.textContent = `Cleared ${r.removedBoxes} gift(s).`; status.classList.remove('error'); await loadGifts(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); await loadGifts(); } else if (tab === 'troll') { body.innerHTML = `

Troll

Loading
`; let username = accountId; try { username = (await get(`${API}/admin/accounts/${accountId}`)).username || accountId; } catch (error) { } body.innerHTML = `

Troll

Grim Labubu
TrollDeveloper-only. These are joke/punitive tools - use with care.
`; const fakeBoxBtn = document.querySelector('[data-act="fake-box"]'); if (fakeBoxBtn) fakeBoxBtn.addEventListener('click', async () => { if (!confirm('Send fake level-up box and ban this account? This is a developer-only troll feature.')) return; const tokenAmount = Number(prompt('Fake token amount (for display only):', '100000') || 100000); try { const r = await send(`${API}/admin/accounts/${accountId}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: true }); alert(`Fake box sent with ${r.tokenAmount} tokens. Account banned.`); await playerDetail(accountId); } catch (error) { alert(error.message); } }); const fakeBoxNoBanBtn = document.querySelector('[data-act="fake-box-no-ban"]'); if (fakeBoxNoBanBtn) fakeBoxNoBanBtn.addEventListener('click', async () => { if (!confirm('Send a fake level-up box without banning this account? This is a developer-only troll feature.')) return; const tokenAmount = Number(prompt('Fake token amount (for display only):', '100000') || 100000); try { const r = await send(`${API}/admin/accounts/${accountId}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: false }); alert(`Fake box sent with ${r.tokenAmount} tokens. Account not banned.`); } catch (error) { alert(error.message); } }); const kickToRoomBtn = document.querySelector('[data-act="kick-to-room"]'); if (kickToRoomBtn) kickToRoomBtn.addEventListener('click', async () => { const roomId = prompt('Room ID (leave empty to kick to your current instance):'); if (roomId === null) return; try { const r = await send(`${API}/admin/accounts/${accountId}/troll/kick-to-room`, 'POST', { roomId: roomId ? Number(roomId) : null }); alert(`Kicked to room: ${r.roomName || 'your instance'}`); } catch (error) { alert(error.message); } }); const forceIntoBtn = document.querySelector('[data-act="force-into"]'); if (forceIntoBtn) forceIntoBtn.addEventListener('click', async () => { if (!currentUser?.isDeveloper) return; if (!confirm('Force this player into your current room instance?')) return; try { const r = await send(`${API}/admin/accounts/${accountId}/force-into/${currentUser.accountId}`, 'POST', {}); alert(r.delivered ? `Forced into "${r.roomName}" live.` : `Record updated to "${r.roomName}" - will apply on next connection.`); } catch (error) { alert(error.message); } }); } else { body.innerHTML = `

This tab isn't wired up yet.

`; } } async function ageVerificationView() { app.innerHTML = `

Age Verification

Players submit a photo (ID or face) against a code from the website; the photo goes to the staff Discord for manual review. Cross-check the code below against the Discord post before approving.

Loading queue
`; const list = document.querySelector('#ageVerificationList'); async function load() { list.innerHTML = '
Loading queue
'; try { const queue = await get(`${API}/admin/age-verification`); if (!queue.length) { list.innerHTML = '

Nothing awaiting review.

'; return; } list.innerHTML = queue.map(item => `
${esc(item.displayName)} @${esc(item.username || '')} · ID ${item.accountId}
Code ${esc(item.code)} · ${esc(item.method === 'ManualId' ? 'Manual ID Verification' : 'Face Verification (Manually)')}${item.submittedAt ? ` · ${new Date(item.submittedAt).toLocaleString()}` : ''}
`).join(''); list.querySelectorAll('[data-act="approve"]').forEach(btn => btn.addEventListener('click', async () => { const code = btn.closest('[data-code]').dataset.code; if (!confirm(`Approve age verification for code ${code}? This clears their Junior status.`)) return; try { await send(`${API}/admin/age-verification/${code}/approve`, 'POST', {}); await load(); } catch (error) { alert(error.message); } })); list.querySelectorAll('[data-act="reject"]').forEach(btn => btn.addEventListener('click', async () => { const code = btn.closest('[data-code]').dataset.code; const reason = prompt('Reason for rejection (optional):') || null; try { await send(`${API}/admin/age-verification/${code}/reject`, 'POST', { reason }); await load(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } await load(); } async function liveInstances() { app.innerHTML = `

Live Room Instances

Loading live instances
`; const list = document.querySelector('#instancesList'); async function loadInstances() { list.innerHTML = '
Loading live instances
'; try { const data = await get(`${API}/admin/instances`); if (!data.instances.length) { list.innerHTML = '

No players currently online.

'; return; } list.innerHTML = `

${data.totalOnline} players online across ${data.instances.length} instances.

` + data.instances.map(instance => `

${esc(instance.room)} ${instance.roomId ? `(Room #${instance.roomId})` : ''}

${instance.playerCount} player(s) · Instance ID: ${instance.roomInstanceId || 'N/A'}

${instance.players.map(p => `
${esc(p.displayName || p.username)}
@${esc(p.username)} · ${p.device} · ${p.sockets} socket(s)
View
`).join('')}
`).join(''); list.querySelectorAll('.shutdown-instance').forEach(btn => btn.addEventListener('click', async () => { if (!confirm('Shutdown this instance? All players will be sent to their dorm.')) return; try { await send(`${API}/admin/instances/${btn.dataset.instanceId}/shutdown`, 'POST', {}); await loadInstances(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } await loadInstances(); } async function roomsList() { app.innerHTML = `

Rooms

`; const input = document.querySelector('#roomSearch'), results = document.querySelector('#roomListResults'); const includeDorms = document.querySelector('#roomIncludeDorms'); const pager = document.querySelector('#roomListPager'); let roomListSkip = 0; const roomListTake = 20; async function runSearch() { results.innerHTML = '
Loading rooms
'; try { const data = await get(`${API}/admin/rooms?search=${encodeURIComponent(input.value.trim())}&includeDorms=${includeDorms.checked}&skip=${roomListSkip}&take=${roomListTake}`); const rooms = data.results ?? data; const total = data.total ?? rooms.length; results.innerHTML = rooms.length ? rooms.map(r => `
${esc(r.name || 'Untitled room')}
ID ${r.roomId} · by ${esc(r.creatorName || r.creatorAccountId)}${r.onlinePlayers ? ` · ${r.onlinePlayers} online` : ''}
`).join('') : '

No rooms found.

'; pager.style.display = total > roomListTake ? 'flex' : 'none'; document.querySelector('#roomListPageInfo').textContent = total ? `${roomListSkip + 1}-${Math.min(roomListSkip + roomListTake, total)} of ${total}` : ''; document.querySelector('#roomListPrev').disabled = roomListSkip <= 0; document.querySelector('#roomListNext').disabled = roomListSkip + roomListTake >= total; } catch (error) { results.innerHTML = `

${esc(error.message)}

`; } } const roomSearchGo = document.querySelector('#roomSearchGo'); if (roomSearchGo) roomSearchGo.addEventListener('click', () => { roomListSkip = 0; runSearch(); }); if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') { roomListSkip = 0; runSearch(); } }); if (includeDorms) includeDorms.addEventListener('change', () => { roomListSkip = 0; runSearch(); }); document.querySelector('#roomListPrev').addEventListener('click', () => { roomListSkip = Math.max(0, roomListSkip - roomListTake); runSearch(); }); document.querySelector('#roomListNext').addEventListener('click', () => { roomListSkip += roomListTake; runSearch(); }); await runSearch(); } async function roomDetail(roomIdRaw) { const roomId = Number(roomIdRaw); app.innerHTML = '
Loading room
'; let room; try { room = await get(`${API}/admin/rooms/${roomId}`); } catch (error) { app.innerHTML = `← Back to Rooms

${esc(error.message)}

`; return; } app.innerHTML = ` ← Back to Rooms
${esc(room.name || 'Untitled room')}
${esc(room.description || '(no description)')}
ID: ${room.roomId} · Creator: ${esc(room.creator?.displayName || room.creator?.username || room.creatorAccountId)}
Details
Subrooms
Roles
Bans
`; document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab)); renderRoomTab(tab.dataset.tab, roomId); })); renderRoomTab('details', roomId); } const ROOM_FLAG_LABELS = { cloningAllowed: 'Cloning allowed', disableMicAutoMute: 'Disable mic auto-mute', disableRoomComments: 'Disable room comments', encryptVoiceChat: 'Encrypt voice chat', toxmodEnabled: 'Toxmod enabled', loadScreenLocked: 'Lock loading screen', autoLocalizeRoom: 'Auto-localize room', isDeveloperOwned: 'Developer owned', supportsLevelVoting: 'Supports level voting', isRRO: 'Rec Room Original', supportsScreens: 'Supports screens', supportsWalkVR: 'Walk VR', supportsTeleportVR: 'Teleport VR', supportsVRLow: 'Low-end VR', supportsQuest2: 'Quest 2', supportsMobile: 'Mobile', supportsJuniors: 'Junior accounts' }; const ROOM_ASSIGNED_ROLES = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner', 'Banned']; const ROOM_INVITED_ROLES = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner']; async function renderRoomTab(tab, roomId) { const body = document.querySelector('#tabBody'); if (tab === 'details') { body.innerHTML = `

Room Details

Summary

Loading...

Edit Room

Developer-only.

Compatibility & behavior flags

${Object.entries(ROOM_FLAG_LABELS).map(([key, label]) => ``).join('')}

Room stats

Transfer ownership

Developer-only. The current owner becomes CoOwner.

Change room ID

Developer-only. Renumbers this room's ID and migrates its subrooms, saves, bans, and creator-feature data (cloud variables, inventions, room player data). Anything that only softly references the old ID (players' visited/favorited room lists) is left as-is - it'll just stop resolving.

`; try { const room = await get(`${API}/admin/rooms/${roomId}`); document.querySelector('#roomName').value = room.name || ''; document.querySelector('#roomImage').value = room.imageName || ''; document.querySelector('#roomAccessibility').value = room.accessibility || 'Public'; document.querySelector('#roomState').value = room.state || 'Active'; document.querySelector('#roomMaxPlayers').value = room.maxPlayers || 8; document.querySelector('#roomMinLevel').value = room.minLevel || 0; document.querySelector('#roomTags').value = (room.tags || []).join(', '); document.querySelector('#roomDesc').value = room.description || ''; document.querySelectorAll('#roomFlagGrid [data-flag]').forEach(input => { input.checked = !!room.flags?.[input.dataset.flag]; }); document.querySelector('#statCheers').value = room.stats?.cheers ?? 0; document.querySelector('#statFavorites').value = room.stats?.favorites ?? 0; document.querySelector('#statVisitors').value = room.stats?.visitors ?? 0; document.querySelector('#statVisits').value = room.stats?.visits ?? 0; document.querySelector('#roomSummaryCard').innerHTML = `

Summary

Accessibility: ${esc(room.accessibility)} · State: ${esc(room.state)} · Max players: ${room.maxPlayers} · Min level: ${room.minLevel}

Tags: ${esc((room.tags || []).join(', ') || 'none')}

`; } catch (error) { body.innerHTML = `

${esc(error.message)}

`; return; } applyRoleGating(); const saveRoomBtn = document.querySelector('#saveRoom'); if (saveRoomBtn) saveRoomBtn.addEventListener('click', async () => { const status = document.querySelector('#roomStatus'); const flags = {}; document.querySelectorAll('#roomFlagGrid [data-flag]').forEach(input => { flags[input.dataset.flag] = input.checked; }); try { status.textContent = 'Saving...'; status.classList.remove('error'); await send(`${API}/admin/rooms/${roomId}`, 'PUT', { name: document.querySelector('#roomName').value, description: document.querySelector('#roomDesc').value, imageName: document.querySelector('#roomImage').value, accessibility: document.querySelector('#roomAccessibility').value, state: document.querySelector('#roomState').value, maxPlayers: parseInt(document.querySelector('#roomMaxPlayers').value) || 1, minLevel: parseInt(document.querySelector('#roomMinLevel').value) || 0, tags: document.querySelector('#roomTags').value.split(',').map(t => t.trim()).filter(Boolean), ...flags }); status.textContent = 'Saved successfully.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const saveStatsBtn = document.querySelector('#saveStats'); if (saveStatsBtn) saveStatsBtn.addEventListener('click', async () => { const status = document.querySelector('#statStatus'); try { await send(`${API}/admin/rooms/${roomId}/stats`, 'PUT', { cheers: parseInt(document.querySelector('#statCheers').value) || 0, favorites: parseInt(document.querySelector('#statFavorites').value) || 0, visitors: parseInt(document.querySelector('#statVisitors').value) || 0, visits: parseInt(document.querySelector('#statVisits').value) || 0 }); status.textContent = 'Saved.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const transferBtn = document.querySelector('#transferOwner'); if (transferBtn) transferBtn.addEventListener('click', async () => { const status = document.querySelector('#transferStatus'); const accountId = Number(document.querySelector('#newOwnerId').value); if (!accountId) return; if (!confirm(`Transfer ownership of this room to account ${accountId}? The current owner becomes CoOwner.`)) return; try { await send(`${API}/admin/rooms/${roomId}/transfer-owner`, 'POST', { accountId }); status.textContent = 'Transferred.'; status.classList.remove('error'); await roomDetail(roomId); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const changeIdBtn = document.querySelector('#changeRoomId'); if (changeIdBtn) changeIdBtn.addEventListener('click', async () => { const status = document.querySelector('#changeRoomIdStatus'); const newRoomId = Number(document.querySelector('#newRoomId').value); if (!newRoomId) return; if (!confirm(`Change this room's ID from ${roomId} to ${newRoomId}? Anything holding the old ID directly (bookmarks, external links) will break.`)) return; try { await send(`${API}/admin/rooms/${roomId}/change-id`, 'POST', { newRoomId }); location.hash = `#rooms/${newRoomId}`; } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); } else if (tab === 'subrooms') { body.innerHTML = `

Subrooms

Create subroom

Loading subrooms
`; async function loadSubrooms() { const list = document.querySelector('#subroomList'); try { const room = await get(`${API}/admin/rooms/${roomId}`); if (!room.subRooms || !room.subRooms.length) { list.innerHTML = '

No subrooms.

'; return; } list.innerHTML = room.subRooms.map(sr => `

${esc(sr.name || 'Untitled')} #${sr.subRoomId} · Save #${sr.currentSaveId || 0}${sr.hasData ? ' · Has data' : ''} · ${esc(sr.accessibility)}

Persistence blob pair (files must already exist in CDN/room)

`).join(''); applyRoleGating(); list.querySelectorAll('.save-subroom').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('[data-id]'), id = card.dataset.id; try { await send(`${API}/admin/rooms/${roomId}/subrooms/${id}`, 'PUT', { name: card.querySelector('[data-field="name"]').value, maxPlayers: parseInt(card.querySelector('[data-field="maxPlayers"]').value) || 1, accessibility: card.querySelector('[data-field="accessibility"]').value, isSandbox: card.querySelector('[data-field="isSandbox"]').checked, unitySceneId: card.querySelector('[data-field="unitySceneId"]').value }); await loadSubrooms(); } catch (error) { alert(error.message); } })); list.querySelectorAll('.clone-subroom').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('[data-id]'), id = card.dataset.id; try { await send(`${API}/admin/rooms/${roomId}/subrooms/${id}/clone`, 'POST'); await loadSubrooms(); } catch (error) { alert(error.message); } })); list.querySelectorAll('.delete-subroom').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('[data-id]'), id = card.dataset.id; if (!confirm('Delete this subroom? A room must keep at least one.')) return; try { await send(`${API}/admin/rooms/${roomId}/subrooms/${id}`, 'DELETE'); await loadSubrooms(); } catch (error) { alert(error.message); } })); list.querySelectorAll('.save-blobs').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('[data-id]'), id = card.dataset.id; try { await send(`${API}/admin/rooms/${roomId}/subrooms/${id}/blobs`, 'PUT', { roomBlob: card.querySelector('[data-field="roomBlob"]').value, metadataBlob: card.querySelector('[data-field="metadataBlob"]').value }); await loadSubrooms(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } applyRoleGating(); const createSubroomBtn = document.querySelector('#createSubroom'); if (createSubroomBtn) createSubroomBtn.addEventListener('click', async () => { const status = document.querySelector('#createSubroomStatus'); const nameInput = document.querySelector('#newSubroomName'); const name = nameInput.value.trim(); if (!name) return; try { await send(`${API}/admin/rooms/${roomId}/subrooms`, 'POST', { name }); nameInput.value = ''; status.textContent = ''; status.classList.remove('error'); await loadSubrooms(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); await loadSubrooms(); } else if (tab === 'roles') { body.innerHTML = `

Room Roles

Add / Update Role

Developer-only.

Current Roles

Loading roles
`; applyRoleGating(); async function loadRoles() { const list = document.querySelector('#roleList'); try { const room = await get(`${API}/admin/rooms/${roomId}`); if (!room.roles || !room.roles.length) { list.innerHTML = '

No roles assigned.

'; return; } list.innerHTML = room.roles.map(r => `
${esc(r.player?.displayName || r.player?.username || r.accountId)}
${esc(r.role)}${r.invitedRole && r.invitedRole !== 'None' ? ` · Invited: ${esc(r.invitedRole)}` : ''}
`).join(''); applyRoleGating(); list.querySelectorAll('.delete-role').forEach(btn => btn.addEventListener('click', async () => { if (!confirm('Remove this role?')) return; try { await send(`${API}/admin/rooms/${roomId}/roles/${btn.dataset.id}`, 'DELETE'); await loadRoles(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } const addRoleBtn = document.querySelector('#addRole'); if (addRoleBtn) addRoleBtn.addEventListener('click', async () => { const accountId = document.querySelector('#roleAccountId'); const roleType = document.querySelector('#roleType'); const invitedRoleType = document.querySelector('#invitedRoleType'); const accId = Number(accountId.value); if (!accId) return; try { await send(`${API}/admin/rooms/${roomId}/roles/${accId}`, 'PUT', { role: roleType.value, invitedRole: invitedRoleType.value }); accountId.value = ''; await loadRoles(); } catch (error) { alert(error.message); } }); await loadRoles(); } else if (tab === 'bans') { body.innerHTML = `

Room Bans

Add Ban

Current Bans

Loading bans
`; async function loadBans() { const list = document.querySelector('#banList'); try { const room = await get(`${API}/admin/rooms/${roomId}`); if (!room.bans || !room.bans.length) { list.innerHTML = '

No bans.

'; return; } list.innerHTML = room.bans.map(b => `
${esc(b.displayName || b.username || b.accountId)}
${esc(b.reason || 'No reason')}
`).join(''); list.querySelectorAll('.delete-ban').forEach(btn => btn.addEventListener('click', async () => { if (!confirm('Unban this player?')) return; try { await send(`${API}/admin/rooms/${roomId}/bans/${btn.dataset.id}`, 'DELETE'); await loadBans(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } const addBanBtn = document.querySelector('#addBan'); if (addBanBtn) addBanBtn.addEventListener('click', async () => { const accountId = document.querySelector('#banAccountId'); const banReason = document.querySelector('#banReason'); if (!accountId || !banReason) return; const accId = Number(accountId.value); if (!accId) return; try { await send(`${API}/admin/rooms/${roomId}/bans`, 'POST', { accountId: accId, reason: banReason.value }); accountId.value = ''; banReason.value = ''; await loadBans(); } catch (error) { alert(error.message); } }); await loadBans(); } } async function playerEventsView() { app.innerHTML = `

Player Events

New event



Loading events
`; const evTitle = document.querySelector('#evTitle'); const evDesc = document.querySelector('#evDesc'); const evStart = document.querySelector('#evStart'); const evEnd = document.querySelector('#evEnd'); const evImageFile = document.querySelector('#evImageFile'); const evImageName = document.querySelector('#evImageName'); const evImagePreview = document.querySelector('#evImagePreview'); const evPinned = document.querySelector('#evPinned'); const evEditId = document.querySelector('#evEditId'); const evFormTitle = document.querySelector('#evFormTitle'); const evCreateBtn = document.querySelector('#evCreate'); const evCancelEditBtn = document.querySelector('#evCancelEdit'); evImageFile.addEventListener('change', async () => { const status = document.querySelector('#evStatus'); const file = evImageFile.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); try { const r = await fetch(`${API}/admin/events/image`, { method: 'POST', body: formData, credentials: 'same-origin' }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'Upload failed'); evImageName.value = data.path; evImagePreview.innerHTML = ``; } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); function resetForm() { evEditId.value = ''; evFormTitle.textContent = 'New event'; evCreateBtn.textContent = 'Create event'; evCancelEditBtn.hidden = true; evTitle.value = ''; evDesc.value = ''; evStart.value = ''; evEnd.value = ''; evImageFile.value = ''; evImageName.value = ''; evImagePreview.innerHTML = ''; evPinned.checked = false; } function toLocalInputValue(iso) { if (!iso) return ''; const d = new Date(iso); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); } function editEvent(e) { evEditId.value = e.eventId ?? e.id; evFormTitle.textContent = `Editing "${e.title}"`; evCreateBtn.textContent = 'Save changes'; evCancelEditBtn.hidden = false; evTitle.value = e.title; evDesc.value = e.description || ''; evStart.value = toLocalInputValue(e.startsAt); evEnd.value = toLocalInputValue(e.endsAt); evImageName.value = e.image ? decodeURIComponent(e.image.replace('/imageserver/', '')) : ''; evImagePreview.innerHTML = e.image ? `` : ''; evPinned.checked = !!e.pinned; window.scrollTo({ top: 0, behavior: 'smooth' }); } async function load() { const list = document.querySelector('#evList'); try { const items = await get(`${API}/admin/events`); if (!items.length) { list.innerHTML = '

No events yet.

'; return; } list.innerHTML = items.map(e => `
${e.image ? `` : ''}

${esc(e.title)} ${e.pinned ? '📌' : ''}

${esc(e.description)}

${e.startsAt ? new Date(e.startsAt).toLocaleString() : ''}${e.endsAt ? ' - ' + new Date(e.endsAt).toLocaleString() : ''}

`).join(''); list.querySelectorAll('.edit-ev').forEach((btn, i) => btn.addEventListener('click', () => editEvent(items[i]))); list.querySelectorAll('.delete-ev').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('[data-id]'), id = card.dataset.id; if (!confirm('Delete this event?')) return; try { await send(`${API}/admin/events/${id}`, 'DELETE'); card.remove(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } if (evCreateBtn) evCreateBtn.addEventListener('click', async () => { const status = document.querySelector('#evStatus'); const title = evTitle.value.trim(); const description = evDesc.value.trim(); const startsAt = evStart.value; const endsAt = evEnd.value; if (!title || !startsAt) { status.textContent = 'Title and start time are required.'; status.classList.add('error'); return; } const payload = { title, description, imageName: evImageName.value || null, startsAt: new Date(startsAt).toISOString(), endsAt: endsAt ? new Date(endsAt).toISOString() : null, pinned: evPinned.checked }; try { if (evEditId.value) { await send(`${API}/admin/events/${evEditId.value}`, 'PUT', payload); status.textContent = 'Saved.'; } else { await send(`${API}/admin/events`, 'POST', payload); status.textContent = 'Created.'; } status.classList.remove('error'); resetForm(); await load(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); if (evCancelEditBtn) evCancelEditBtn.addEventListener('click', resetForm); await load(); } function simpleMarkdownPreview(markdown) { return esc(markdown) .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/\n/g, '
'); } const ANNOUNCEMENT_KINDS = ['info', 'update', 'warning', 'maintenance']; async function announcementsView() { app.innerHTML = `

Announcements

New announcement

Preview



Loading announcements
`; const annTitle = document.querySelector('#annTitle'); const annKind = document.querySelector('#annKind'); const annBody = document.querySelector('#annBody'); const annPreview = document.querySelector('#annPreview'); const annPinned = document.querySelector('#annPinned'); const annPublished = document.querySelector('#annPublished'); const annEditId = document.querySelector('#annEditId'); const annFormTitle = document.querySelector('#annFormTitle'); const annCreateBtn = document.querySelector('#annCreate'); const annCancelEditBtn = document.querySelector('#annCancelEdit'); annBody.addEventListener('input', () => { annPreview.innerHTML = simpleMarkdownPreview(annBody.value) || 'Nothing to preview yet.'; }); function resetForm() { annEditId.value = ''; annFormTitle.textContent = 'New announcement'; annCreateBtn.textContent = 'Post announcement'; annCancelEditBtn.hidden = true; annTitle.value = ''; annKind.value = 'info'; annBody.value = ''; annPreview.innerHTML = ''; annPinned.checked = false; annPublished.checked = true; } function editAnnouncement(a) { annEditId.value = a.id; annFormTitle.textContent = `Editing "${a.title}"`; annCreateBtn.textContent = 'Save changes'; annCancelEditBtn.hidden = false; annTitle.value = a.title; annKind.value = a.kind; annBody.value = a.bodyMarkdown; annPreview.innerHTML = simpleMarkdownPreview(a.bodyMarkdown); annPinned.checked = a.pinned; annPublished.checked = a.published; window.scrollTo({ top: 0, behavior: 'smooth' }); } async function load() { const list = document.querySelector('#annList'); try { const items = await get(`${API}/admin/announcements`); if (!items.length) { list.innerHTML = '

No announcements yet.

'; return; } list.innerHTML = items.map(a => `

${esc(a.title)} ${esc(a.kind)} ${a.pinned ? '📌' : ''} ${a.published ? '' : 'Draft'}

${esc(a.bodyMarkdown)}

`).join(''); list.querySelectorAll('.edit-ann').forEach((btn, i) => btn.addEventListener('click', () => editAnnouncement(items[i]))); list.querySelectorAll('.delete-ann').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('[data-id]'), id = card.dataset.id; if (!confirm('Delete this announcement?')) return; try { await send(`${API}/admin/announcements/${id}`, 'DELETE'); card.remove(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } if (annCreateBtn) annCreateBtn.addEventListener('click', async () => { const status = document.querySelector('#annStatus'); const title = annTitle.value.trim(); const bodyMarkdown = annBody.value.trim(); if (!title || !bodyMarkdown) { status.textContent = 'Title and body are required.'; status.classList.add('error'); return; } const payload = { title, bodyMarkdown, kind: annKind.value, pinned: annPinned.checked, published: annPublished.checked }; try { if (annEditId.value) { await send(`${API}/admin/announcements/${annEditId.value}`, 'PUT', payload); status.textContent = 'Saved.'; } else { await send(`${API}/admin/announcements`, 'POST', payload); status.textContent = 'Posted.'; } status.classList.remove('error'); resetForm(); await load(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); if (annCancelEditBtn) annCancelEditBtn.addEventListener('click', resetForm); await load(); } async function communityBoardView() { app.innerHTML = '

Community Board

Loading board
'; try { const board = await get(`${API}/admin/community-board`); renderCommunityBoard(board); } catch (error) { app.innerHTML = `

Community Board

${esc(error.message)}

`; } } function renderCommunityBoard(board) { app.innerHTML = `

Community Board

Featured Player





Featured Rooms

Instagram Images

Videos

Current Announcement



Scheduled maintenance countdown

Pushes a live in-game maintenance notice. Set to 0 to clear it.



`; const fpId = document.querySelector('#fpId'); const fpTitle = document.querySelector('#fpTitle'); const fpUrl = document.querySelector('#fpUrl'); const annMessage = document.querySelector('#annMessage'); const annUrl = document.querySelector('#annUrl'); let featuredRooms = [...(board.featuredRoomGroup?.featuredRooms || [])]; let instagramImages = [...(board.instagramImages || [])]; let videos = [...(board.videos || [])]; function renderFeaturedRooms() { const container = document.querySelector('#featuredRoomsList'); container.innerHTML = featuredRooms.map((room, i) => `
`).join(''); container.querySelectorAll('.remove-fr').forEach(btn => btn.addEventListener('click', () => { featuredRooms.splice(btn.dataset.index, 1); renderFeaturedRooms(); })); container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => { const idx = e.target.dataset.index; const field = e.target.classList.contains('fr-roomId') ? 'roomId' : e.target.classList.contains('fr-roomName') ? 'roomName' : 'imageName'; featuredRooms[idx][field] = e.target.value; })); } function renderInstagram() { const container = document.querySelector('#instagramList'); container.innerHTML = instagramImages.map((img, i) => `
`).join(''); container.querySelectorAll('.remove-insta').forEach(btn => btn.addEventListener('click', () => { instagramImages.splice(btn.dataset.index, 1); renderInstagram(); })); container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => { instagramImages[e.target.dataset.index].imageUrl = e.target.value; })); } function renderVideos() { const container = document.querySelector('#videosList'); container.innerHTML = videos.map((vid, i) => `
`).join(''); container.querySelectorAll('.remove-vid').forEach(btn => btn.addEventListener('click', () => { videos.splice(btn.dataset.index, 1); renderVideos(); })); container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => { const idx = e.target.dataset.index; const field = e.target.classList.contains('vid-title') ? 'title' : 'description'; videos[idx][field] = e.target.value; })); } renderFeaturedRooms(); renderInstagram(); renderVideos(); const addFeaturedRoomBtn = document.querySelector('#addFeaturedRoom'); if (addFeaturedRoomBtn) addFeaturedRoomBtn.addEventListener('click', () => { featuredRooms.push({ roomId: '', roomName: '', imageName: '' }); renderFeaturedRooms(); }); const addInstagramBtn = document.querySelector('#addInstagram'); if (addInstagramBtn) addInstagramBtn.addEventListener('click', () => { instagramImages.push({ imageUrl: '' }); renderInstagram(); }); const addVideoBtn = document.querySelector('#addVideo'); if (addVideoBtn) addVideoBtn.addEventListener('click', () => { videos.push({ title: '', description: '' }); renderVideos(); }); applyRoleGating(); const startMaintenanceBtn = document.querySelector('#startMaintenance'); if (startMaintenanceBtn) startMaintenanceBtn.addEventListener('click', async () => { const status = document.querySelector('#maintenanceStatus'); const minutes = parseInt(document.querySelector('#maintenanceMinutes').value) || 0; if (minutes > 0 && !confirm(`Push a live ${minutes}-minute maintenance countdown to every player in-game?`)) return; try { const r = await send(`${API}/admin/maintenance`, 'POST', { minutes }); status.textContent = r.message; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const saveBoardBtn = document.querySelector('#saveBoard'); if (saveBoardBtn) saveBoardBtn.addEventListener('click', async () => { const status = document.querySelector('#boardStatus'); if (!status) return; const newBoard = { featuredPlayer: fpId.value ? { id: Number(fpId.value), titleOverride: fpTitle.value, urlOverride: fpUrl.value } : null, featuredRoomGroup: featuredRooms.length ? { featuredRooms } : null, instagramImages: instagramImages, videos: videos, currentAnnouncement: annMessage.value ? { message: annMessage.value, moreInfoUrl: annUrl.value } : null }; status.textContent = 'Saving...'; status.classList.remove('error'); try { await send(`${API}/admin/community-board`, 'PUT', newBoard); status.textContent = 'Saved successfully.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); const resetBoardBtn = document.querySelector('#resetBoard'); if (resetBoardBtn) resetBoardBtn.addEventListener('click', async () => { const status = document.querySelector('#boardStatus'); if (!status) return; status.textContent = 'Reloading...'; status.classList.remove('error'); try { const freshBoard = await get(`${API}/admin/community-board`); renderCommunityBoard(freshBoard); status.textContent = 'Reset to server data.'; } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); } async function clubsView() { app.innerHTML = `

Clubs

`; const input = document.querySelector('#clubSearch'), results = document.querySelector('#clubListResults'); async function loadClubs() { results.innerHTML = '
Loading clubs
'; try { const data = await get(`${API}/admin/clubs?search=${encodeURIComponent(input.value.trim())}`); if (!data.clubs.length) { results.innerHTML = '

No clubs found.

'; return; } results.innerHTML = data.clubs.map(c => `
${esc(c.name)}
ID ${c.clubId} · ${c.memberCount} members · ${c.visibility}
`).join(''); } catch (error) { results.innerHTML = `

${esc(error.message)}

`; } } const clubSearchGo = document.querySelector('#clubSearchGo'); if (clubSearchGo) clubSearchGo.addEventListener('click', loadClubs); if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') loadClubs(); }); await loadClubs(); } async function clubDetail(clubIdRaw) { const clubId = Number(clubIdRaw); app.innerHTML = '
Loading club
'; let club; try { club = await get(`${API}/admin/clubs/${clubId}`); } catch (error) { app.innerHTML = `← Back to Clubs

${esc(error.message)}

`; return; } const summary = club.summary; app.innerHTML = ` ← Back to Clubs
${esc(summary.name)}
${esc(summary.description || '(no description)')}
ID: ${summary.clubId} · ${summary.memberCount} members
State: ${summary.state} · Visibility: ${summary.visibility}
Creator: ${summary.creatorAccountId}
Back
Members
`; document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab)); renderClubTab(tab.dataset.tab, clubId); })); renderClubTab('members', clubId); const editBtn = document.querySelector('[data-act="edit"]'); if (editBtn) editBtn.addEventListener('click', async () => { const name = prompt('Club name:', summary.name); if (name === null) return; const description = prompt('Description:', summary.description || ''); if (description === null) return; const state = prompt('State (Active/MarkedForDelete):', summary.state); if (state === null) return; const visibility = prompt('Visibility (Public/Private):', summary.visibility); if (visibility === null) return; try { await send(`${API}/admin/clubs/${clubId}`, 'PUT', { name, description, state, visibility }); await clubDetail(clubIdRaw); } catch (error) { alert(error.message); } }); } async function renderClubTab(tab, clubId) { const body = document.querySelector('#tabBody'); if (tab === 'members') { body.innerHTML = `

Members

Loading members
`; try { const members = await get(`${API}/admin/clubs/${clubId}/members`); if (!members.length) { body.innerHTML = '

No members.

'; return; } body.innerHTML = `${members.map(m => ` `).join('')}
Account IDUsernameDisplay NameRoleActions
${m.accountId} ${esc(m.username || 'N/A')} ${esc(m.displayName || 'N/A')} ${esc(m.membershipType)}
`; body.querySelectorAll('.set-role').forEach(btn => btn.addEventListener('click', async () => { const row = btn.closest('tr'), accountId = row.dataset.account; const role = prompt('Membership type (Creator/Admin/Officer/Member):'); if (!role) return; try { await send(`${API}/admin/clubs/${clubId}/members/${accountId}`, 'POST', { membershipType: role }); await renderClubTab('members', clubId); } catch (error) { alert(error.message); } })); body.querySelectorAll('.remove-member').forEach(btn => btn.addEventListener('click', async () => { const row = btn.closest('tr'), accountId = row.dataset.account; if (!confirm('Remove this member?')) return; try { await send(`${API}/admin/clubs/${clubId}/members/${accountId}`, 'DELETE'); await renderClubTab('members', clubId); } catch (error) { alert(error.message); } })); } catch (error) { body.innerHTML = `

${esc(error.message)}

`; } } } const GIFT_CONTEXT_DEFS = [['None', -1], ['Default'], ['First_Activity'], ['Game_Drop'], ['All_Daily_Challenges_Complete'], ['All_Weekly_Challenge_Complete'], ['Daily_Challenge_Complete'], ['Weekly_Challenge_Complete'], ['Unassigned_Equipment', 10], ['Unassigned_Avatar'], ['Unassigned_Consumable'], ['Reacquisition', 20], ['Membership'], ['NUX_TokensAndDressUp', 30], ['NUX_Experiment1'], ['NUX_Experiment2'], ['NUX_Experiment3'], ['NUX_Experiment4'], ['NUX_Experiment5'], ['GameRewards', 50], ['GameRewards_Tokens'], ['LevelUp', 100], ['Purchased_Gift_A', 500], ['Purchased_Gift_B'], ['Purchased_Gift_C'], ['Purchased_Gift_D'], ['Holiday', 1000], ['Contest'], ['Promotion'], ['SubscribersOnly'], ['Deprecated', 1100], ['RecRoyale', 1200], ['DEPRECATED_Paintball_ClearCut', 2000], ['DEPRECATED_Paintball_Homestead'], ['DEPRECATED_Paintball_Quarry'], ['DEPRECATED_Paintball_River'], ['DEPRECATED_Paintball_Dam'], ['DEPRECATED_Paintball_DriveIn'], ['Paintball_ClearCut', 2010], ['Paintball_Homestead'], ['Paintball_Quarry'], ['Paintball_River'], ['Paintball_Dam'], ['Paintball_DriveIn'], ['DEPRECATED_Discgolf_Propulsion', 3000], ['DEPRECATED_Discgolf_Lake'], ['Discgolf_Propulsion', 3010], ['Discgolf_Lake'], ['Discgolf_Mode_CoopCatch', 3500], ['Quest_Goblin_A', 4000], ['Quest_Goblin_B'], ['Quest_Goblin_C'], ['Quest_Goblin_S'], ['Quest_Goblin_Consumable'], ['Quest_Cauldron_A', 4010], ['Quest_Cauldron_B'], ['Quest_Cauldron_C'], ['Quest_Cauldron_S'], ['Quest_Cauldron_Consumable'], ['Quest_Pirate1_A', 4100], ['Quest_Pirate1_B'], ['Quest_Pirate1_C'], ['Quest_Pirate1_S'], ['Quest_Pirate1_X'], ['Quest_Pirate1_Consumable'], ['Quest_Dracula1_A', 4200], ['Quest_Dracula1_B'], ['Quest_Dracula1_C'], ['Quest_Dracula1_S'], ['Quest_Dracula1_X'], ['Quest_Dracula1_Consumable'], ['Quest_Dracula1_SS'], ['Quest_SciFi_A', 4500], ['Quest_SciFi_B'], ['Quest_SciFi_C'], ['Quest_SciFi_S'], ['Quest_Scifi_Consumable'], ['DEPRECATED_Charades', 5000], ['Charades'], ['DEPRECATED_Soccer', 6000], ['Soccer'], ['DEPRECATED_Paddleball', 7000], ['Paddleball'], ['DEPRECATED_Dodgeball', 8000], ['Dodgeball'], ['DEPRECATED_Lasertag', 9000], ['Lasertag'], ['DEPRECATED_Bowling', 10000], ['Bowling'], ['StuntRunner_TheMainEvent_A', 11000], ['StuntRunner_TheMainEvent_B'], ['StuntRunner_TheMainEvent_C'], ['StuntRunner_TheMainEvent_D'], ['StuntRunner_TheMainEvent_S'], ['StuntRunner_TheMainEvent_X'], ['StuntRunner_TheMainEvent_Consumable'], ['StuntRunner_TheMainEvent_SS'], ['Store_LaserTag', 100000], ['Store_RecCenter', 100010], ['Consumable', 110000], ['Token', 110100], ['Punchcard_Challenge_Complete', 110200], ['All_Punchcard_Challenges_Complete'], ['Commerce_Purchase', 200000]]; const GIFT_CONTEXTS = (() => { let next = 0; return GIFT_CONTEXT_DEFS.map(([name, value]) => { const v = value !== undefined ? value : next; next = v + 1; return { name, value: v }; }); })(); async function coachGiftingView() { app.innerHTML = `

Coach Gifting

Send Gift



















Gift Catalog

`; const giftType = document.querySelector('#giftType'); const skuLabel = document.querySelector('#skuLabel'); const amountLabel = document.querySelector('#amountLabel'); const boxRarityLabel = document.querySelector('#boxRarityLabel'); const skuId = document.querySelector('#skuId'); const amount = document.querySelector('#amount'); const boxRarity = document.querySelector('#boxRarity'); const boxDesign = document.querySelector('#boxDesign'); const boxDesignCustom = document.querySelector('#boxDesignCustom'); (function populateGiftContexts() { const customOption = boxDesign.querySelector('option[value="custom"]'); for (const { name, value } of GIFT_CONTEXTS) { if (value === 2 || value === 110000) continue; const opt = document.createElement('option'); opt.value = String(value); opt.textContent = `${name} (${value})`; boxDesign.insertBefore(opt, customOption); } })(); function resolveBoxDesign() { return boxDesign.value === 'custom' ? Number(boxDesignCustom.value || 0) : Number(boxDesign.value); } function updateFormVisibility() { const type = giftType.value; skuLabel.style.display = (type === 'avatar' || type === 'equipment' || type === 'consumable') ? '' : 'none'; amountLabel.style.display = (type === 'tokens' || type === 'xp' || type === 'consumable') ? '' : 'none'; boxRarityLabel.style.display = type === 'box' ? '' : 'none'; boxDesign.parentElement.style.display = type === 'box' ? '' : 'none'; boxDesignCustom.hidden = !(type === 'box' && boxDesign.value === 'custom'); } if (giftType) giftType.addEventListener('change', updateFormVisibility); if (boxDesign) boxDesign.addEventListener('change', updateFormVisibility); updateFormVisibility(); let catalogSkip = 0; const catalogTake = 20; async function loadCatalog() { const catalogSearch = document.querySelector('#catalogSearch'); const catalogType = document.querySelector('#catalogType'); const results = document.querySelector('#catalogResults'); const pageInfo = document.querySelector('#catalogPageInfo'); const prevBtn = document.querySelector('#catalogPrev'); const nextBtn = document.querySelector('#catalogNext'); if (!catalogSearch || !catalogType || !results) return; const search = catalogSearch.value; const type = catalogType.value; results.innerHTML = '
Loading catalog
'; try { const data = await get(`${API}/admin/gifts/catalog?search=${encodeURIComponent(search)}&type=${type}&skip=${catalogSkip}&take=${catalogTake}`); const items = data.results ?? data; const total = data.total ?? items.length; if (!items.length) { results.innerHTML = '

No items found.

'; } else { results.innerHTML = items.map(item => `
${esc(item.friendlyName)}
SKU ${item.skuId} · ${item.type} ${item.avatarItemId ? `· Avatar #${item.avatarItemId}` : ''}
`).join(''); results.querySelectorAll('.select-sku').forEach(btn => btn.addEventListener('click', () => { skuId.value = btn.closest('[data-sku]').dataset.sku; })); } if (pageInfo) pageInfo.textContent = total ? `${catalogSkip + 1}-${Math.min(catalogSkip + catalogTake, total)} of ${total}` : ''; if (prevBtn) prevBtn.disabled = catalogSkip <= 0; if (nextBtn) nextBtn.disabled = catalogSkip + catalogTake >= total; } catch (error) { results.innerHTML = `

${esc(error.message)}

`; } } const searchCatalogBtn = document.querySelector('#searchCatalog'); if (searchCatalogBtn) searchCatalogBtn.addEventListener('click', () => { catalogSkip = 0; loadCatalog(); }); const catalogSearch = document.querySelector('#catalogSearch'); if (catalogSearch) catalogSearch.addEventListener('keydown', e => { if (e.key === 'Enter') { catalogSkip = 0; loadCatalog(); } }); const catalogPrevBtn = document.querySelector('#catalogPrev'); if (catalogPrevBtn) catalogPrevBtn.addEventListener('click', () => { catalogSkip = Math.max(0, catalogSkip - catalogTake); loadCatalog(); }); const catalogNextBtn = document.querySelector('#catalogNext'); if (catalogNextBtn) catalogNextBtn.addEventListener('click', () => { catalogSkip += catalogTake; loadCatalog(); }); loadCatalog(); const sendGiftBtn = document.querySelector('#sendGift'); if (sendGiftBtn) sendGiftBtn.addEventListener('click', async () => { const status = document.querySelector('#giftStatus'); const recipientIds = document.querySelector('#recipientIds'); const sendToAll = document.querySelector('#sendToAll'); const onlineOnly = document.querySelector('#onlineOnly'); const giftMessage = document.querySelector('#giftMessage'); if (!status || !recipientIds || !sendToAll || !onlineOnly || !giftMessage) return; const recipientAccountIds = recipientIds.value ? recipientIds.value.split(',').map(id => Number(id.trim())).filter(id => id > 0) : []; const request = { GiftType: giftType.value, RecipientAccountIds: recipientAccountIds, SendToAll: sendToAll.checked, OnlineOnly: onlineOnly.checked, Message: giftMessage.value }; if (giftType.value === 'avatar' || giftType.value === 'equipment' || giftType.value === 'consumable') { request.SkuId = Number(skuId.value) || 0; if (giftType.value === 'consumable') request.Amount = Number(amount.value) || 1; } else if (giftType.value === 'tokens' || giftType.value === 'xp') { request.Amount = Number(amount.value) || 0; } else if (giftType.value === 'box') { request.BoxRarity = Number(boxRarity.value) || 10; request.BoxDesign = resolveBoxDesign() || 2; } status.textContent = 'Sending...'; status.classList.remove('error'); try { const result = await send(`${API}/admin/gifts`, 'POST', request); status.textContent = `Gift sent to ${result.queued} players.`; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); } async function configView() { app.innerHTML = `

Server Security

Access Control

Master switch for every account-creation path, including the admin form.



Controls only the website registration form and is also limited by the master switch.



Checks gameplay and account endpoints. Results are cached and provider outages fail open.



`; const accountCreationToggle = document.querySelector('#accountCreationEnabled'); const signupToggle = document.querySelector('#signupEnabled'); const vpnToggle = document.querySelector('#vpnBlockingEnabled'); const proxyCheckHint = document.querySelector('#proxyCheckHint'); const status = document.querySelector('#configStatus'); async function loadSettings() { try { const settings = await get(`${API}/admin/settings`); accountCreationToggle.checked = !!settings.accountCreationEnabled; signupToggle.checked = !!settings.recNetSignupEnabled; vpnToggle.checked = !!settings.vpnBlockingEnabled; proxyCheckHint.textContent = `Proxy-check API key: ${settings.proxyCheckConfigured ? 'configured' : 'not configured (set in server config file)'}`; status.textContent = `Account creation ${settings.accountCreationEnabled ? 'enabled' : 'disabled'} · RecNet signup ${settings.recNetSignupEnabled ? 'enabled' : 'disabled'} · VPN blocking ${settings.vpnBlockingEnabled ? 'enabled' : 'disabled'}`; } catch (error) { status.textContent = error.message; status.classList.add('error'); } } async function saveSettings() { try { accountCreationToggle.disabled = signupToggle.disabled = vpnToggle.disabled = true; status.textContent = 'Saving...'; status.classList.remove('error'); await send(`${API}/admin/settings`, 'PUT', { accountCreationEnabled: accountCreationToggle.checked, recNetSignupEnabled: signupToggle.checked, vpnBlockingEnabled: vpnToggle.checked }); status.textContent = 'Settings saved.'; } catch (error) { status.textContent = error.message; status.classList.add('error'); } finally { accountCreationToggle.disabled = signupToggle.disabled = vpnToggle.disabled = false; } } accountCreationToggle.addEventListener('change', saveSettings); signupToggle.addEventListener('change', saveSettings); vpnToggle.addEventListener('change', saveSettings); await loadSettings(); } async function accountCreationView() { await configView(); } async function avatarItemsView() { app.innerHTML = `

Avatar Items

Upload a JSON file containing the avatar item definition.


Browse Avatar Items

Search to browse the avatar item catalog.

`; let avatarBrowseSkip = 0; const avatarBrowseTake = 20; async function runAvatarBrowse() { const searchInput = document.querySelector('#avatarBrowseSearch'); const results = document.querySelector('#avatarBrowseResults'); const pager = document.querySelector('#avatarBrowsePager'); const query = searchInput.value.trim(); results.innerHTML = '
Loading
'; try { const data = await get(`${API}/admin/gifts/catalog?search=${encodeURIComponent(query)}&type=avatar&skip=${avatarBrowseSkip}&take=${avatarBrowseTake}`); const items = data.results ?? data; const total = data.total ?? items.length; results.innerHTML = items.length ? items.map(item => `
${item.thumbnailImage ? `` : ''}
${esc(item.friendlyName)}
SKU ${item.skuId} · Avatar #${item.avatarItemId} · Rarity ${item.rarity}
`).join('') : '

No items found.

'; pager.style.display = total > avatarBrowseTake ? 'flex' : 'none'; document.querySelector('#avatarBrowsePageInfo').textContent = total ? `${avatarBrowseSkip + 1}-${Math.min(avatarBrowseSkip + avatarBrowseTake, total)} of ${total}` : ''; document.querySelector('#avatarBrowsePrev').disabled = avatarBrowseSkip <= 0; document.querySelector('#avatarBrowseNext').disabled = avatarBrowseSkip + avatarBrowseTake >= total; } catch (error) { results.innerHTML = `

${esc(error.message)}

`; } } const avatarBrowseSearchGo = document.querySelector('#avatarBrowseSearchGo'); if (avatarBrowseSearchGo) avatarBrowseSearchGo.addEventListener('click', () => { avatarBrowseSkip = 0; runAvatarBrowse(); }); const avatarBrowseSearchInput = document.querySelector('#avatarBrowseSearch'); if (avatarBrowseSearchInput) avatarBrowseSearchInput.addEventListener('keydown', e => { if (e.key === 'Enter') { avatarBrowseSkip = 0; runAvatarBrowse(); } }); document.querySelector('#avatarBrowsePrev').addEventListener('click', () => { avatarBrowseSkip = Math.max(0, avatarBrowseSkip - avatarBrowseTake); runAvatarBrowse(); }); document.querySelector('#avatarBrowseNext').addEventListener('click', () => { avatarBrowseSkip += avatarBrowseTake; runAvatarBrowse(); }); const simpleMode = document.querySelector('#simpleMode'); const advancedMode = document.querySelector('#advancedMode'); const fileInput = document.querySelector('#avatarJsonFile'); const textArea = document.querySelector('#avatarJsonText'); const status = document.querySelector('#avatarStatus'); document.querySelectorAll('input[name="avatarMode"]').forEach(radio => { radio.addEventListener('change', () => { simpleMode.style.display = radio.value === 'simple' ? '' : 'none'; advancedMode.style.display = radio.value === 'advanced' ? '' : 'none'; }); }); if (fileInput) fileInput.addEventListener('change', () => { const file = fileInput.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const avatarTextArea = document.querySelector('#avatarJsonText'); if (avatarTextArea) avatarTextArea.value = e.target.result; }; reader.readAsText(file); }); const uploadAvatarItemBtn = document.querySelector('#uploadAvatarItem'); if (uploadAvatarItemBtn) uploadAvatarItemBtn.addEventListener('click', async () => { let json; try { const simpleMode = document.querySelector('#simpleMode'); const advancedMode = document.querySelector('#advancedMode'); const avatarFileInput = document.querySelector('#avatarJsonFile'); const avatarTextArea = document.querySelector('#avatarJsonText'); if (!simpleMode || !advancedMode || !avatarFileInput || !avatarTextArea) return; if (simpleMode.style.display !== 'none') { const file = avatarFileInput.files[0]; if (!file) { alert('Please select a JSON file.'); return; } const text = await file.text(); json = JSON.parse(text); } else { const text = avatarTextArea.value.trim(); if (!text) { alert('Please enter JSON.'); return; } json = JSON.parse(text); } } catch (error) { alert('Invalid JSON: ' + error.message); return; } try { const status = document.querySelector('#avatarStatus'); if (!status) return; status.textContent = 'Uploading...'; status.classList.remove('error'); const result = await send(`${API}/admin/avatar-items`, 'POST', json); status.textContent = result.message || 'Avatar item uploaded successfully.'; } catch (error) { const status = document.querySelector('#avatarStatus'); if (status) { status.textContent = error.message; status.classList.add('error'); } } }); } async function preferencesView() { app.innerHTML = `

Preferences

Loading preferences
`; try { const prefs = await get(`${API}/admin/preferences`); app.innerHTML = `

Preferences

Appearance





`; const savePrefsBtn = document.querySelector('#savePrefs'); if (savePrefsBtn) savePrefsBtn.addEventListener('click', async () => { const status = document.querySelector('#prefsStatus'); const themeSelect = document.querySelector('#themeSelect'); const accentColor = document.querySelector('#accentColor'); if (!status || !themeSelect || !accentColor) return; status.textContent = 'Saving...'; status.classList.remove('error'); try { await send(`${API}/admin/preferences`, 'PUT', { theme: themeSelect.value, accentColor: accentColor.value }); status.textContent = 'Saved successfully.'; applyPreferences({ theme: themeSelect.value, accentColor: accentColor.value }); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); applyPreferences(prefs); } catch (error) { app.innerHTML = `

Preferences

${esc(error.message)}

`; } } function applyPreferences(prefs) { if (!prefs) return; const root = document.documentElement; if (prefs.theme === 'light') { root.style.setProperty('--bg', '#f5f5f5'); root.style.setProperty('--panel', '#ffffff'); root.style.setProperty('--panel-alt', '#f0f0f0'); root.style.setProperty('--text', '#1a1a1a'); root.style.setProperty('--text-dim', '#666666'); root.style.setProperty('--border', '#e0e0e0'); } else { root.style.removeProperty('--bg'); root.style.removeProperty('--panel'); root.style.removeProperty('--panel-alt'); root.style.removeProperty('--text'); root.style.removeProperty('--text-dim'); root.style.removeProperty('--border'); } if (prefs.accentColor) { root.style.setProperty('--primary', prefs.accentColor); } } async function playerReportsView() { app.innerHTML = `

Player Reports

Loading reports
`; const statusFilter = document.querySelector('#reportStatusFilter'); const list = document.querySelector('#playerReportList'); async function resolveReport(id, action) { const body = { action }; if (action === 'noaction') { body.reason = prompt('Optional note:') || ''; } else if (action === 'ban') { const reason = prompt('Ban reason:'); if (!reason) return; body.reason = reason; const durationUnit = (prompt('Duration unit (seconds/minutes/hours/days/weeks/permanent):', 'permanent') || 'permanent').trim().toLowerCase(); body.durationUnit = durationUnit; if (durationUnit !== 'permanent') { body.durationAmount = Number(prompt('Duration amount:', '1') || '1'); } } else { const reason = prompt('Timeout reason:'); if (!reason) return; body.reason = reason; body.durationUnit = (prompt('Timeout unit (seconds/minutes/hours/days), capped at 30 days:', 'hours') || 'hours').trim().toLowerCase(); body.durationAmount = Number(prompt('Timeout amount:', '24') || '24'); } try { await send(`${API}/admin/reports/players/${encodeURIComponent(id)}/resolve`, 'POST', body); await loadReports(); } catch (error) { alert(error.message); } } async function loadReports() { list.innerHTML = '
Loading reports
'; try { const reports = await get(`${API}/admin/reports/players?status=${encodeURIComponent(statusFilter.value)}`); if (!reports.length) { list.innerHTML = '

No reports here.

'; return; } list.innerHTML = reports.map(r => `
${esc(r.reportedUsername || (r.reportedPlayerId ? `Account #${r.reportedPlayerId}` : 'Unknown player'))} ${esc(r.status)}
Reported by ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()} ${r.reportCategory != null ? `
Category #${esc(r.reportCategory)}` : ''} ${r.details ? `
${esc(r.details)}` : ''} ${r.roomId ? `
Room ${esc(r.roomId)}${r.roomInstanceType ? ` (${esc(r.roomInstanceType)})` : ''}` : ''} ${r.resolutionNote ? `
Resolution: ${esc(r.resolutionNote)}` : ''}
${r.status === 'Pending' ? `
` : ''}
`).join(''); list.querySelectorAll('.act-ban').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'ban'))); list.querySelectorAll('.act-timeout').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'timeout'))); list.querySelectorAll('.act-noaction').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'noaction'))); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } statusFilter.addEventListener('change', loadReports); await loadReports(); } async function bugReportsView() { app.innerHTML = `

Bug Reports

Loading bug reports
`; const statusFilter = document.querySelector('#bugStatusFilter'); const list = document.querySelector('#bugReportList'); async function loadBugReports() { list.innerHTML = '
Loading bug reports
'; try { const reports = await get(`${API}/admin/reports/bugs?status=${encodeURIComponent(statusFilter.value)}`); if (!reports.length) { list.innerHTML = '

No bug reports here.

'; return; } list.innerHTML = reports.map(r => `
${esc(r.category || 'Uncategorized')} ${esc(r.status)}
From ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}
${esc(r.description || '')}
`).join(''); list.querySelectorAll('.toggle-bug').forEach(btn => btn.addEventListener('click', async () => { try { await send(`${API}/admin/reports/bugs/${encodeURIComponent(btn.dataset.id)}/resolve`, 'POST', { status: btn.dataset.next }); await loadBugReports(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } statusFilter.addEventListener('change', loadBugReports); await loadBugReports(); } async function ipBansView() { app.innerHTML = `

IP Bans

Add IP Ban





Active Bans

Loading IP bans
`; const networkInput = document.querySelector('#ipBanNetwork'); const reasonInput = document.querySelector('#ipBanReason'); const status = document.querySelector('#ipBanStatus'); const list = document.querySelector('#ipBanList'); async function loadIpBans() { list.innerHTML = '
Loading IP bans
'; try { const bans = await get(`${API}/admin/ip-bans`); if (!bans.length) { list.innerHTML = '

No IP addresses or ranges are banned.

'; return; } list.innerHTML = bans.map(b => `
${esc(b.network)}
${esc(b.reason || 'Blocked by an administrator.')}
Added ${new Date(b.createdAt).toLocaleString()} by account #${b.createdByAccountId || 0}
`).join(''); list.querySelectorAll('.remove-ip').forEach(btn => btn.addEventListener('click', async () => { if (!confirm('Remove this IP ban?')) return; try { await send(`${API}/admin/ip-bans/${encodeURIComponent(btn.dataset.id)}`, 'DELETE', {}); await loadIpBans(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } const addIpBanBtn = document.querySelector('#addIpBan'); if (addIpBanBtn) addIpBanBtn.addEventListener('click', async () => { const network = networkInput.value.trim(); const reason = reasonInput.value.trim(); if (!network) return; try { status.textContent = 'Adding IP ban...'; status.classList.remove('error'); await send(`${API}/admin/ip-bans`, 'POST', { network, reason }); networkInput.value = ''; reasonInput.value = ''; status.textContent = 'IP ban added.'; await loadIpBans(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); await loadIpBans(); } async function steamBlacklistView() { app.innerHTML = `

Steam Blacklist

Blacklist Steam ID

Every Steam ID is allowed by default. Add an ID here to return HTTP 403 on cached login, account creation, password login, refresh, and RecNet login.





Blacklisted IDs

Loading Steam blacklist
`; const steamInput = document.querySelector('#steamId'); const reasonInput = document.querySelector('#steamReason'); const status = document.querySelector('#steamStatus'); const list = document.querySelector('#steamList'); async function loadSteamBlacklist() { list.innerHTML = '
Loading Steam blacklist
'; try { const items = await get(`${API}/admin/steam-blacklist`); if (!items.length) { list.innerHTML = '

No Steam IDs are blacklisted.

'; return; } list.innerHTML = items.map(item => `
${esc(item.steamId)}
${esc(item.reason || 'Blacklisted by an administrator.')}
Added by ${esc(item.addedByDisplayName || item.addedByByUsername || `Account #${item.addedByAccountId}`)} on ${new Date(item.addedAt || item.AddedAt).toLocaleString()}
`).join(''); list.querySelectorAll('.remove-steam').forEach(btn => btn.addEventListener('click', async () => { if (!confirm(`Remove Steam ID ${btn.dataset.steam} from the blacklist?`)) return; try { await send(`${API}/admin/steam-blacklist/${encodeURIComponent(btn.dataset.steam)}`, 'DELETE', {}); await loadSteamBlacklist(); } catch (error) { alert(error.message); } })); } catch (error) { list.innerHTML = `

${esc(error.message)}

`; } } const addSteamBtn = document.querySelector('#addSteam'); if (addSteamBtn) addSteamBtn.addEventListener('click', async () => { const steamId = steamInput.value.trim(); const reason = reasonInput.value.trim(); if (!steamId) return; if (!confirm(`Blacklist Steam ID ${steamId}? Future login and connect attempts will receive HTTP 403.`)) return; try { status.textContent = 'Saving...'; status.classList.remove('error'); await send(`${API}/admin/steam-blacklist`, 'POST', { steamId, reason }); steamInput.value = ''; reasonInput.value = ''; status.textContent = 'Steam ID blacklisted.'; await loadSteamBlacklist(); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); await loadSteamBlacklist(); } async function shopView() { app.innerHTML = `

Shop Controls

Shop Actions

Custom Shop Items

Loading custom items

Add Custom Item

`; const status = document.querySelector('#shopStatus'); const customItems = document.querySelector('#customShopItems'); const searchInput = document.querySelector('#shopSearch'); const searchResults = document.querySelector('#shopSearchResults'); async function loadCustomItems() { customItems.innerHTML = '
Loading custom items
'; try { const items = await get(`${API}/admin/shop`); if (!items.customItems || !items.customItems.length) { customItems.innerHTML = '

No custom items pinned. All 10 slots are random 5-stars.

'; return; } customItems.innerHTML = items.customItems.map(item => `
${esc(item.friendlyName)}
SKU ${item.skuId} · ${item.price} tokens
`).join(''); customItems.querySelectorAll('.remove-shop').forEach(btn => btn.addEventListener('click', async () => { try { const status = document.querySelector('#boardStatus'); status.textContent = 'Saving...'; status.classList.remove('error'); try { await send(`${API}/admin/shop/items/${btn.dataset.sku}`, 'DELETE', {}); status.textContent = 'Saved successfully.'; status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } } catch (error) { alert(error.message); } })); } catch (error) { customItems.innerHTML = `

${esc(error.message)}

`; } } const refreshShopBtn = document.querySelector('#refreshShop'); if (refreshShopBtn) refreshShopBtn.addEventListener('click', async () => { const status = document.querySelector('#shopStatus'); if (!status) return; try { status.textContent = 'Refreshing shop...'; status.classList.remove('error'); await send(`${API}/admin/shop/refresh`, 'POST', {}); status.textContent = 'Shop refreshed.'; } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); let shopSearchSkip = 0; const shopSearchTake = 20; async function runShopSearch() { const query = searchInput.value.trim(); const pager = document.querySelector('#shopSearchPager'); if (!query) { searchResults.innerHTML = ''; pager.style.display = 'none'; return; } try { const data = await get(`${API}/admin/shop/catalog?search=${encodeURIComponent(query)}&skip=${shopSearchSkip}&take=${shopSearchTake}`); const items = data.results ?? data; const total = data.total ?? items.length; if (!items.length) { searchResults.innerHTML = '

No items found.

'; } else { searchResults.innerHTML = items.map(item => `
${esc(item.friendlyName)}
SKU ${item.skuId} · Avatar #${item.avatarItemId || 'N/A'}
`).join(''); searchResults.querySelectorAll('.add-shop').forEach(btn => btn.addEventListener('click', async (e) => { e.stopPropagation(); const sku = btn.closest('[data-sku]').dataset.sku; try { await send(`${API}/admin/shop/custom-items`, 'POST', { skuId: Number(sku) }); await loadCustomItems(); } catch (error) { alert(error.message); } })); } pager.style.display = total > shopSearchTake ? 'flex' : 'none'; document.querySelector('#shopSearchPageInfo').textContent = total ? `${shopSearchSkip + 1}-${Math.min(shopSearchSkip + shopSearchTake, total)} of ${total}` : ''; document.querySelector('#shopSearchPrev').disabled = shopSearchSkip <= 0; document.querySelector('#shopSearchNext').disabled = shopSearchSkip + shopSearchTake >= total; } catch (error) { searchResults.innerHTML = `

${esc(error.message)}

`; } } let searchTimer; searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { shopSearchSkip = 0; runShopSearch(); }, 300); }); document.querySelector('#shopSearchPrev').addEventListener('click', () => { shopSearchSkip = Math.max(0, shopSearchSkip - shopSearchTake); runShopSearch(); }); document.querySelector('#shopSearchNext').addEventListener('click', () => { shopSearchSkip += shopSearchTake; runShopSearch(); }); await loadCustomItems(); } function describeImportResult(result) { let text = `Imported! Room ID: ${result.roomId}, Subrooms: ${result.subRoomsImported}, Saves: ${result.savesImported}`; if (result.bakedAssetsImported) text += `, Baked assets: ${result.bakedAssetsImported}`; if (result.assetBundlesCopied) text += ` (${result.assetBundlesCopied} bundles copied${result.assetBundlesMissing ? `, ${result.assetBundlesMissing} missing` : ''})`; if (result.unityEngineVersions?.length) text += `. Unity versions: ${result.unityEngineVersions.join(', ')}`; return text; } async function pollMassImportJob(jobId, status, progressBox) { while (true) { let job; try { job = await get(`${API}/admin/rooms/import-batch/${jobId}`); } catch (error) { status.textContent = `Lost track of the import job: ${error.message}`; status.classList.add('error'); return; } const total = job.totalFound || 0; const done = job.completedCount || 0; if (job.status === 'pending') { status.textContent = 'Finding rooms...'; } else if (job.status === 'running') { status.textContent = `Importing ${done}/${total}${job.currentRoomName ? ` - ${job.currentRoomName}` : ''}...`; } else if (job.status === 'failed') { status.textContent = `Batch failed: ${job.fatalError || 'unknown error'}`; status.classList.add('error'); } else { status.textContent = `Done: ${job.successCount}/${total} imported${job.failedCount ? `, ${job.failedCount} failed` : ''}.`; } if (progressBox) { progressBox.innerHTML = (job.results || []).map(r => { const detail = r.success ? ` (room ${r.roomId}, ${r.subRoomsImported} subroom${r.subRoomsImported === 1 ? '' : 's'}${r.bakedAssetsImported ? `, ${r.bakedAssetsImported} baked assets` : ''})` : ` — ${r.error}`; const color = r.success ? '#4caf50' : '#ff5555'; return `
${r.success ? '✓' : '✗'} ${r.name}${detail}
`; }).join(''); } if (job.status === 'completed' || job.status === 'failed') return; await new Promise(resolve => setTimeout(resolve, 1500)); } } async function roomImporterView() { app.innerHTML = `

Room Importer

Import Room

Upload a room export ZIP, paste Unity scene JSON metadata, or import straight from an archive URL (e.g. an archive.splootybean.com-style room folder). Baked asset bundles are picked up automatically in all three modes - no separate step needed.











`; const importMethod = document.querySelector('#importMethod'); const zipSection = document.querySelector('#zipSection'); const urlSection = document.querySelector('#urlSection'); const jsonSection = document.querySelector('#jsonSection'); const massToggle = document.querySelector('#massImportToggle'); const massHint = document.querySelector('#massImportHint'); const importBtnLabel = document.querySelector('#importRoom'); function applyMassLabels() { const isMass = massToggle && massToggle.checked; massHint.style.display = isMass ? '' : 'none'; document.querySelector('#roomZipLabel').firstChild.textContent = isMass ? 'Rooms Export ZIP (multiple room folders)' : 'Room Export ZIP (.zip)'; document.querySelector('#roomUrlLabel').firstChild.textContent = isMass ? 'Rooms index URL' : 'Room folder URL'; document.querySelector('#roomUrl').placeholder = isMass ? 'https://archive.splootybean.com/Rooms/' : 'https://archive.splootybean.com/Rooms/Showdown/'; document.querySelector('#roomUrlHint').textContent = isMass ? 'Point this at the index folder that lists every room\'s own folder (e.g. .../Rooms/) - each room folder found directly under it gets crawled and imported in turn. Same per-room 1 GiB cap, plus a 300 room-folder cap on the listing itself.' : 'Point this at the page that directly lists room.json and the numbered subroom folders - not the parent Rooms/ index. The server crawls that listing itself: room.json, every subroom\'s saves, and only the specific baked asset bundles this room\'s save data actually references (not the whole shared AssetBundles/ folder). Capped at 1 GiB total.'; document.querySelector('#roomJsonLabel').firstChild.textContent = isMass ? 'JSON array of room metadata objects' : 'Unity Scene JSON Metadata'; document.querySelector('#roomJson').placeholder = isMass ? 'Paste a JSON array here: [{...room 1...}, {...room 2...}]' : 'Paste Unity scene JSON metadata here...'; if (importBtnLabel) importBtnLabel.textContent = isMass ? 'Import Rooms' : 'Import Room'; } if (massToggle) massToggle.addEventListener('change', applyMassLabels); applyMassLabels(); if (importMethod) importMethod.addEventListener('change', () => { zipSection.style.display = importMethod.value === 'zip' ? '' : 'none'; urlSection.style.display = importMethod.value === 'url' ? '' : 'none'; jsonSection.style.display = importMethod.value === 'json' ? '' : 'none'; }); const importBtn = document.querySelector('#importRoom'); if (importBtn) importBtn.addEventListener('click', async () => { const status = document.querySelector('#importStatus'); const progressBox = document.querySelector('#massProgress'); if (!status) return; status.textContent = 'Importing...'; status.classList.remove('error'); if (progressBox) progressBox.innerHTML = ''; const isMass = massToggle && massToggle.checked; try { if (isMass) { let startResult; if (importMethod.value === 'json') { const roomJson = document.querySelector('#roomJson'); if (!roomJson || !roomJson.value.trim()) { status.textContent = 'Please paste a JSON array of room metadata objects.'; status.classList.add('error'); return; } startResult = await send(`${API}/admin/rooms/import-batch/json`, 'POST', roomJson.value.trim()); } else if (importMethod.value === 'url') { const roomUrl = document.querySelector('#roomUrl'); const urlCreatorAccountId = document.querySelector('#urlCreatorAccountId'); const urlReplaceExisting = document.querySelector('#urlReplaceExisting'); if (!roomUrl || !roomUrl.value.trim()) { status.textContent = 'Please enter the rooms index URL.'; status.classList.add('error'); return; } status.textContent = 'Finding room folders...'; startResult = await send(`${API}/admin/rooms/import-batch/url`, 'POST', { url: roomUrl.value.trim(), creatorAccountId: urlCreatorAccountId?.value ? Number(urlCreatorAccountId.value) : null, replaceExisting: urlReplaceExisting ? urlReplaceExisting.checked : true }); } else { const roomZip = document.querySelector('#roomZip'); const creatorAccountId = document.querySelector('#creatorAccountId'); const replaceExisting = document.querySelector('#replaceExisting'); if (!roomZip || !roomZip.files || !roomZip.files[0]) { status.textContent = 'Please select a ZIP file.'; status.classList.add('error'); return; } const formData = new FormData(); formData.append('file', roomZip.files[0]); if (creatorAccountId && creatorAccountId.value) { formData.append('creatorAccountId', creatorAccountId.value); } if (replaceExisting) { formData.append('replaceExisting', replaceExisting.checked.toString()); } status.textContent = 'Uploading...'; const response = await fetch(`${API}/admin/rooms/import-batch/zip`, { method: 'POST', body: formData }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || `Request failed (${response.status})`); } startResult = await response.json(); } await pollMassImportJob(startResult.jobId, status, progressBox); return; } if (importMethod.value === 'json') { const roomJson = document.querySelector('#roomJson'); if (!roomJson || !roomJson.value.trim()) { status.textContent = 'Please paste JSON metadata.'; status.classList.add('error'); return; } const result = await send(`${API}/admin/rooms/import`, 'POST', roomJson.value.trim()); status.textContent = describeImportResult(result); } else if (importMethod.value === 'url') { const roomUrl = document.querySelector('#roomUrl'); const urlCreatorAccountId = document.querySelector('#urlCreatorAccountId'); const urlReplaceExisting = document.querySelector('#urlReplaceExisting'); if (!roomUrl || !roomUrl.value.trim()) { status.textContent = 'Please enter the room folder URL.'; status.classList.add('error'); return; } status.textContent = 'Starting crawl...'; const startResult = await send(`${API}/admin/rooms/import-url`, 'POST', { url: roomUrl.value.trim(), creatorAccountId: urlCreatorAccountId?.value ? Number(urlCreatorAccountId.value) : null, replaceExisting: urlReplaceExisting ? urlReplaceExisting.checked : true }); await pollMassImportJob(startResult.jobId, status, progressBox); return; } else { const roomZip = document.querySelector('#roomZip'); const creatorAccountId = document.querySelector('#creatorAccountId'); const replaceExisting = document.querySelector('#replaceExisting'); if (!roomZip || !roomZip.files || !roomZip.files[0]) { status.textContent = 'Please select a ZIP file.'; status.classList.add('error'); return; } const formData = new FormData(); formData.append('file', roomZip.files[0]); if (creatorAccountId && creatorAccountId.value) { formData.append('creatorAccountId', creatorAccountId.value); } if (replaceExisting) { formData.append('replaceExisting', replaceExisting.checked.toString()); } status.textContent = 'Uploading...'; const response = await fetch(`${API}/admin/rooms/import`, { method: 'POST', body: formData }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || `Request failed (${response.status})`); } const startResult = await response.json(); await pollMassImportJob(startResult.jobId, status, progressBox); return; } status.classList.remove('error'); } catch (error) { status.textContent = error.message; status.classList.add('error'); } }); } async function logsView() { app.innerHTML = `

Server Logs

The last few thousand lines of server console output, kept in memory (cleared on restart). Not a substitute for a real log file, but enough to see what's happening right now.


    
`; const output = document.querySelector('#logsOutput'); const takeInput = document.querySelector('#logsTake'); const autoRefresh = document.querySelector('#logsAutoRefresh'); let timer = null; async function load() { const take = Math.max(50, Math.min(5000, Number(takeInput.value) || 500)); try { const result = await get(`${API}/admin/logs?take=${take}`); output.textContent = (result.lines || []).join('\n') || '(no log lines yet)'; output.scrollTop = output.scrollHeight; } catch (error) { output.textContent = `Error loading logs: ${error.message}`; } } document.querySelector('#logsRefresh').addEventListener('click', load); autoRefresh.addEventListener('change', () => { if (timer) { clearInterval(timer); timer = null; } if (autoRefresh.checked) timer = setInterval(load, 5000); }); await load(); } async function scannerLogsView() { app.innerHTML = `

Loser Scanner Logs

Every request that hit a path nothing on this server recognizes - nobody legitimate does that by hand, so this is scanner/bot traffic going fishing for stuff like .env, aws config files, wp-login.php, .git/config, and the like. Highlighted rows matched one of those patterns specifically; everything else just hit a URL that doesn't exist here.

`; const resultsEl = document.querySelector('#scannerResults'); const totalEl = document.querySelector('#scannerTotal'); const ipFilter = document.querySelector('#scannerIpFilter'); const loadMoreBtn = document.querySelector('#scannerLoadMore'); const pageSize = 100; let skip = 0; let rows = []; function renderRows() { if (!rows.length) { resultsEl.innerHTML = '

No scanner attempts logged yet.

'; return; } resultsEl.innerHTML = rows.map(r => `
${esc(new Date(r.timestamp).toLocaleString())} · ${esc(r.ipAddress)} · ${esc(r.method)} ${esc(r.path)}${r.queryString ? esc(r.queryString) : ''}
${r.matchedPattern ? `
matched: ${esc(r.matchedPattern)}
` : ''} ${r.userAgent ? `
${esc(r.userAgent)}
` : ''}
`).join(''); } async function load(reset) { if (reset) { skip = 0; rows = []; } try { const ip = ipFilter.value.trim(); const result = await get(`${API}/admin/scanner-logs?skip=${skip}&take=${pageSize}${ip ? `&ip=${encodeURIComponent(ip)}` : ''}`); rows = rows.concat(result.results || []); skip = rows.length; totalEl.textContent = `${result.total} total attempt${result.total === 1 ? '' : 's'} logged`; loadMoreBtn.style.display = (result.results || []).length === pageSize ? '' : 'none'; renderRows(); } catch (error) { resultsEl.innerHTML = `

${esc(error.message)}

`; } } document.querySelector('#scannerRefresh').addEventListener('click', () => load(true)); ipFilter.addEventListener('keydown', e => { if (e.key === 'Enter') load(true); }); loadMoreBtn.addEventListener('click', () => load(false)); await load(true); } async function anticheatLogsView() { app.innerHTML = `

Anticheat Logs

Every time the client mod caught something bad on its own machine - a debugger attached, a proxy sitting in the traffic path, a known injector process or module, or an unrecognized MelonLoader mod riding along - it reported here and force-closed itself.

`; const resultsEl = document.querySelector('#acResults'); const totalEl = document.querySelector('#acTotal'); const ipFilter = document.querySelector('#acIpFilter'); const loadMoreBtn = document.querySelector('#acLoadMore'); const pageSize = 100; let skip = 0; let rows = []; function renderRows() { if (!rows.length) { resultsEl.innerHTML = '

No anticheat flags logged yet.

'; return; } resultsEl.innerHTML = rows.map(r => `
${esc(new Date(r.timestamp).toLocaleString())} · ${esc(r.ipAddress)}${r.accountId ? ` · account ${r.accountId}` : ''} · steam ${esc(r.steamId || '0')} · build ${esc(r.build || '?')}
${esc(r.flags)}
${r.userAgent ? `
${esc(r.userAgent)}
` : ''}
`).join(''); } async function load(reset) { if (reset) { skip = 0; rows = []; } try { const ip = ipFilter.value.trim(); const result = await get(`${API}/admin/anticheat-logs?skip=${skip}&take=${pageSize}${ip ? `&ip=${encodeURIComponent(ip)}` : ''}`); rows = rows.concat(result.results || []); skip = rows.length; totalEl.textContent = `${result.total} total flag${result.total === 1 ? '' : 's'} logged`; loadMoreBtn.style.display = (result.results || []).length === pageSize ? '' : 'none'; renderRows(); } catch (error) { resultsEl.innerHTML = `

${esc(error.message)}

`; } } document.querySelector('#acRefresh').addEventListener('click', () => load(true)); ipFilter.addEventListener('keydown', e => { if (e.key === 'Enter') load(true); }); loadMoreBtn.addEventListener('click', () => load(false)); await load(true); } async function boot() { try { currentUser = await get(`${API}/auth/me`); } catch { currentUser = null; } if (!currentUser || (!currentUser.isModerator && !currentUser.isDeveloper && !currentUser.isAdmin)) { renderLoginGate(); return; } applyRoleGating(); try { const prefs = await get(`${API}/admin/preferences`); if (prefs) applyPreferences(prefs); } catch (e) { } await route(); setInterval(checkSystemStatus, 10000); } async function checkSystemStatus() { try { const status = await get(`${API}/admin/system-status`); const banner = document.querySelector('#ddosBanner'); if (status.isUnderAttack) { if (!banner) { const bannerEl = document.createElement('div'); bannerEl.id = 'ddosBanner'; bannerEl.style.cssText = 'position:fixed;top:60px;left:0;right:0;background:#ff4444;color:white;padding:12px;text-align:center;font-weight:bold;z-index:9999;animation:pulse 1s infinite'; bannerEl.innerHTML = '⚠️ WE\'RE GETTING DDOSED :c ⚠️'; document.body.appendChild(bannerEl); } } else if (banner) { banner.remove(); } } catch {} } boot();