Astral's Stream Sniper

Combines parallel processing speed, a stunning astral theme, and a robust retry mechanism to handle API rate limits.

// ==UserScript==
// @name         Astral's Stream Sniper
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  Combines parallel processing speed, a stunning astral theme, and a robust retry mechanism to handle API rate limits.
// @author       Liyaa aka Ilyax
// @match        https://*.roblox.com/*
// @icon         https://i.imgur.com/83AaG5v.png
// @grant        none
// @license      None
// ==/UserScript==

// Copyright © 2025 Astral
// This script may not be copied, modified, or redistributed. 
// Writted by Ilyax

(function() {
    'use strict';

    const DEBUG_MODE = true;

    const _dataCache = new Map();
    const _config = {
        batchSize: 100,
        concurrentBatches: 10,
        retryLimit: 5,
        initialBackoff: 1000,
    };

    const _debug = {
        logContainer: null,
        log(message) {
            if (DEBUG_MODE) {
                if (!this.logContainer) this.logContainer = document.getElementById('astral-debug-log-content');
                if (!this.logContainer) return;
                const p = document.createElement('p');
                p.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
                this.logContainer.appendChild(p);
                this.logContainer.scrollTop = this.logContainer.scrollHeight;
            }
        }
    };

    const _utils = {
        getPlaceId: () => window.location.href.match(/games\/(\d+)/)?.[1],
        delay: (ms) => new Promise(resolve => setTimeout(resolve, ms)),
    };

    const _api = {
        execute: async (url, options = {}, isRetryable = true) => {
            for (let retries = 0; retries < _config.retryLimit; retries++) {
                try {
                    const config = { credentials: 'include', headers: { 'Content-Type': 'application/json', ...options.headers }, ...options };
                    const response = await fetch(url, config);
                    if (!response.ok) {
                        if (response.status === 429 && isRetryable) {
                            throw new Error(`RateLimited`);
                        }
                        throw new Error(`HTTP Error: ${response.status}`);
                    }
                    return response.json();
                } catch (error) {
                    if (error.message.includes('RateLimited')) {
                        const backoff = _config.initialBackoff * Math.pow(2, retries);
                        _debug.log(`API Rate Limit (429). Waiting ${backoff}ms... (Retry ${retries + 1}/${_config.retryLimit})`);
                        await _utils.delay(backoff);
                    } else {
                        _debug.log(`Critical API Error: ${error.message} on ${url}`);
                        throw error;
                    }
                }
            }
            throw new Error(`API request failed after ${_config.retryLimit} retries on ${url}`);
        },
        getUserId: async (username) => {
            if (_dataCache.has(username)) return _dataCache.get(username);
            _debug.log(`Fetching user ID for '${username}'...`);
            const response = await _api.execute('https://users.roblox.com/v1/usernames/users', {
                method: 'POST', body: JSON.stringify({ usernames: [username], excludeBannedUsers: true }),
            }, false);
            const userId = response.data[0]?.id;
            if (userId) {
                _dataCache.set(username, userId);
                _debug.log(`User ID found: ${userId}`);
            }
            return userId;
        },
        getUserThumbnail: async (userId) => {
            const cacheKey = `thumb_${userId}`;
            if (_dataCache.has(cacheKey)) return _dataCache.get(cacheKey);
            _debug.log(`Fetching thumbnail for ID ${userId}...`);
            const response = await _api.execute(`https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${userId}&format=Png&size=150x150`);
            const thumbUrl = response.data[0]?.imageUrl;
            if (thumbUrl) _dataCache.set(cacheKey, thumbUrl);
            return thumbUrl;
        },
        getGameServers: async (placeId, cursor = null) => {
            let url = `https://games.roblox.com/v1/games/${placeId}/servers/Public?limit=100`;
            if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`;
            return _api.execute(url);
        },
        getBatchThumbnails: async (tokens) => {
            const body = tokens.map(token => ({
                requestId: `0:${token}:AvatarHeadshot:150x150:png:regular`, type: 'AvatarHeadShot', targetId: 0, token, format: 'png', size: '150x150'
            }));
            return _api.execute('https://thumbnails.roblox.com/v1/batch', { method: 'POST', body: JSON.stringify(body) });
        },
    };

    const _core = {
        isSearching: false,
        stopSearch: false,
        search: async (placeId, username, ui) => {
            if (_core.isSearching) return;
            _core.isSearching = true;
            _core.stopSearch = false;
            ui.updateStatus("Searching...", true);

            const startTime = Date.now();
            try {
                ui.updateStatus("Fetching user data...", true);
                const userId = await _api.getUserId(username);
                if (!userId) { ui.updateStatus("User not found.", false); return; }

                const targetThumbUrl = await _api.getUserThumbnail(userId);
                if (!targetThumbUrl) { ui.updateStatus("Failed to get user image.", false); return; }
                ui.setThumbnail(targetThumbUrl);
                ui.updateStatus("User image loaded.", true);

                _debug.log("Collecting server data...");
                ui.updateStatus("Collecting server data...", true);
                let cursor = null;
                const allTokens = [];
                do {
                    const servers = await _api.getGameServers(placeId, cursor);
                    if (!servers || _core.stopSearch) break;
                    servers.data.forEach(server => server.playerTokens.forEach(token => allTokens.push({ token, server })));
                    cursor = servers.nextPageCursor;
                    ui.updateStatus(`Collected ${allTokens.length} player tokens...`, true);
                } while (cursor && !_core.stopSearch);

                if (allTokens.length === 0) { ui.updateStatus("No active servers found.", false); return; }

                _debug.log(`Total ${allTokens.length} players found. Starting parallel scan...`);

                const tokenBatches = [];
                for (let i = 0; i < allTokens.length; i += _config.batchSize) {
                    tokenBatches.push(allTokens.slice(i, i + _config.batchSize));
                }

                let playersProcessed = 0;
                for (let i = 0; i < tokenBatches.length; i += _config.concurrentBatches) {
                    if (_core.stopSearch) break;

                    const concurrentGroup = tokenBatches.slice(i, i + _config.concurrentBatches);
                    const promises = concurrentGroup.map(batch => _api.getBatchThumbnails(batch.map(item => item.token)));

                    _debug.log(`Processing ${concurrentGroup.length} batches (${concurrentGroup.length * _config.batchSize} players) in parallel.`);

                    const results = await Promise.all(promises);

                    for (const result of results) {
                        const foundThumb = result.data?.find(thumb => thumb?.imageUrl === targetThumbUrl);
                        if (foundThumb) {
                            _core.stopSearch = true;
                            const thumbToken = foundThumb.requestId.split(':')[1];
                            const originalBatch = allTokens.find(item => item.token === thumbToken);
                            const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
                            ui.updateStatus(`Target found! Search completed in ${elapsed} seconds.`, false);
                            _debug.log(`TARGET FOUND! Time: ${elapsed}s.`);
                            ui.showResult(placeId, originalBatch.server.id);
                            return;
                        }
                    }
                    playersProcessed += concurrentGroup.length * _config.batchSize;
                    ui.updateStatus(`Processed ${Math.min(playersProcessed, allTokens.length)}/${allTokens.length} players...`, true);
                }

                if (!_core.stopSearch) ui.updateStatus(`User not found in ${allTokens.length} players.`, false);

            } catch (error) {
                console.error("Search Error:", error);
                ui.updateStatus(`Error: ${error.message}`, false);
                _debug.log(`CRITICAL ERROR: ${error.message}`);
            } finally {
                _core.isSearching = false;
                ui.updateStatus(ui.elements.status.textContent, false);
            }
        }
    };

    const _ui = {
        elements: {},
        createStyles: () => {},
        initialize: () => {
            const targetContainer = document.getElementById('running-game-instances-container');
            if (!targetContainer) { setTimeout(_ui.initialize, 500); return; }
            if (document.getElementById('astral-sniper-container')) return;

            _ui.createStyles();

            const app = document.createElement('div');
            app.id = 'astral-sniper-container';
            app.innerHTML = `
                <div id="astral-sniper-header">
                    <img id="astral-sniper-thumb">
                    <h2>Astral Sniper</h2>
                </div>
                <form id="astral-sniper-form" onsubmit="return false;">
                    <input type="text" id="astral-sniper-username" placeholder="Username..." autocomplete="off">
                    <button type="submit" id="astral-sniper-submit" class="astral-btn">Search</button>
                </form>
                <p id="astral-sniper-status">Enter a username to begin.</p>
                <div id="astral-sniper-result-container"></div>
                <div id="astral-debug-log">
                    <h3>Debugger</h3>
                    <div id="astral-debug-log-content"></div>
                </div>
            `;
            targetContainer.prepend(app);

            const ids = ['form', 'username', 'submit', 'status', 'result-container', 'thumb'];
            ids.forEach(id => _ui.elements[id] = document.getElementById(`astral-sniper-${id}`));

            _ui.elements.form.addEventListener('submit', () => {
                _ui.elements['result-container'].innerHTML = '';
                _ui.elements.thumb.style.display = 'none';
                _core.search(_utils.getPlaceId(), _ui.elements.username.value, _ui);
            });
        },
        updateStatus: (text, isSearching) => {
            if (_ui.elements.status) {
                _ui.elements.status.textContent = text;
                _ui.elements.submit.disabled = isSearching;
            }
        },
        setThumbnail: (src) => { if (_ui.elements.thumb) { _ui.elements.thumb.src = src; _ui.elements.thumb.style.display = 'block'; } },
        showResult: (placeId, jobId) => {
            const joinBtn = document.createElement('button');
            joinBtn.id = 'astral-sniper-join';
            joinBtn.className = 'astral-btn';
            joinBtn.textContent = 'Join Game Instance';
            joinBtn.onclick = () => window.Roblox?.GameLauncher?.joinGameInstance?.(placeId, jobId);
            // HATA BURADAYDI: _ui.elements.result -> _ui.elements['result-container'] olarak düzeltildi.
            const resultContainer = _ui.elements['result-container'];
            resultContainer.innerHTML = '';
            resultContainer.appendChild(joinBtn);
        },
    };

    // UI stillerini tekrar ekleyelim, bir önceki kodda kısaltılmıştı
    _ui.createStyles = () => {
        const styleSheet = document.createElement('style');
        styleSheet.innerHTML = `
            @keyframes stars {
                0% { background-position: 0 0; } 100% { background-position: 0 1000px; }
            }
            #astral-sniper-container {
                background: #000 url(https://i.imgur.com/gKKf42I.png);
                animation: stars 60s linear infinite;
                border: 1px solid #5A41A5; border-radius: 12px;
                padding: 16px; margin-bottom: 20px;
                font-family: 'Segoe UI', 'Roboto', sans-serif;
                box-shadow: 0 0 15px rgba(128, 90, 213, 0.5);
            }
            #astral-sniper-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
            #astral-sniper-header h2 { font-size: 22px; font-weight: 600; margin: 0; color: #fff; text-shadow: 0 0 8px #fff; }
            #astral-sniper-thumb { border-radius: 50%; width: 48px; height: 48px; display: none; border: 2px solid #5A41A5; }
            #astral-sniper-form { display: flex; gap: 10px; margin-bottom: 12px; }
            #astral-sniper-username {
                flex-grow: 1; border: 1px solid #5A41A5; background-color: rgba(0,0,0,0.5);
                border-radius: 8px; padding: 10px 14px; font-size: 16px; color: #fff;
            }
            #astral-sniper-username:focus { outline: none; box-shadow: 0 0 8px #805AD5; }
            .astral-btn {
                border: none; border-radius: 8px; padding: 0 18px; font-size: 16px; font-weight: 600; cursor: pointer;
                transition: all 0.2s ease; text-shadow: 0 0 5px rgba(255,255,255,0.7);
            }
            #astral-sniper-submit { background: linear-gradient(45deg, #8A2BE2, #4B0082); color: white; }
            #astral-sniper-submit:hover { transform: scale(1.05); box-shadow: 0 0 15px #8A2BE2; }
            #astral-sniper-submit:disabled { background: #555; cursor: not-allowed; transform: none; box-shadow: none; }
            #astral-sniper-join { background: linear-gradient(45deg, #00FF7F, #32CD32); color: white; width: 100%; padding: 12px; display: block; }
            #astral-sniper-join:hover { transform: scale(1.02); box-shadow: 0 0 15px #00FF7F; }
            #astral-sniper-status { font-size: 14px; color: #ccc; min-height: 20px; text-align: center; }
            #astral-debug-log { display: ${DEBUG_MODE ? 'block' : 'none'}; margin-top: 15px; border-top: 1px solid #5A41A5; padding-top: 10px; }
            #astral-debug-log h3 { margin: 0 0 5px 0; color: #fff; text-align: left; font-size: 14px; }
            #astral-debug-log-content {
                background-color: rgba(0,0,0,0.7); border: 1px solid #333; border-radius: 5px;
                max-height: 150px; overflow-y: auto; text-align: left; padding: 8px; font-family: 'Consolas', monospace; font-size: 12px;
            }
            #astral-debug-log-content p { margin: 0; padding: 2px 0; border-bottom: 1px dotted #444; color: #00FF7F; }
        `;
        document.head.appendChild(styleSheet);
    };

    const initInterval = setInterval(() => {
        if(document.getElementById('running-game-instances-container')) {
            clearInterval(initInterval);
            _ui.initialize();
        }
    }, 500);
})();