Draftmancer BoosterCard Inspector

Locate all BoosterCard instances and display delta winrate from 17lands data

// ==UserScript==
// @name         Draftmancer BoosterCard Inspector
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  Locate all BoosterCard instances and display delta winrate from 17lands data
// @homepage     https://greasyfork.org/scripts/545265
// @supportURL   https://greasyfork.org/scripts/545265/feedback
// @author       xiaoas
// @match        https://draftmancer.com/*
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    let cardRatingsList = []; // Aggregated list of ratings across expansions
    let cardRatingsByName = {}; // Map: name -> rating entry
    let cardRatingsByMtgaId = {}; // Map: mtga_id -> rating entry
    let currentExpansion = 'EOE'; // Default to EOE
    let lastCardNames = []; // Track last known card names
    let refreshInterval = null;
    let isRequestInProgress = false; // Prevent overlapping requests
    let isLoopInProgress = false; // Prevent overlapping main loop runs
    const queriedExpansions = new Set(); // Track expansions whose data has been fetched
    const fetchPromisesByExpansion = {}; // Track in-flight fetches per expansion
    let activeFetchCount = 0; // Track number of active fetches

    // Expansion mapping from page names to 17lands parameters
    const expansionMapping = {
        "Edge of Eternities": "EOE",
        "Final Fantasy": "FIN",
        "Tarkir: Dragonstorm": "TDM",
        "Aetherdrift": "DFT"
    };

    // Function to detect current expansion from page
    function detectExpansion() {
        try {
            const setElement = document.querySelector('.selected-set-name');
            if (setElement) {
                const setText = setElement.innerText.trim();
                currentExpansion = expansionMapping[setText];
                // console.log(`🎯 Detected expansion: "${setText}" -> ${currentExpansion}`);
                return currentExpansion;
            }
        } catch (error) {
            console.error('❌ Error detecting expansion:', error);
        }
        return currentExpansion;
    }

    function inferExpansionsFromSetName(setString) {
        const s = (setString || '').toLowerCase();
        const inferred = new Set();
        if (s.includes('eoe') || s.includes('eos')) inferred.add('EOE');
        if (s.includes('fin')) inferred.add('FIN');
        if (s.includes('tdm')) inferred.add('TDM');
        if (s.includes('dft') || s.includes('spg')) inferred.add('DFT');
        return Array.from(inferred);
    }

    // Function to calculate weighted average win rate for an expansion
    function calculateWeightedAverageWinRate(expansionData) {
        if (!expansionData || expansionData.length === 0) return 0;
        
        let totalWeightedWinRate = 0;
        let totalGames = 0;
        
        expansionData.forEach(card => {
            if (card.ever_drawn_win_rate !== undefined && card.ever_drawn_game_count) {
                totalWeightedWinRate += card.ever_drawn_win_rate * card.ever_drawn_game_count;
                totalGames += card.ever_drawn_game_count;
            }
        });
        
        return totalGames > 0 ? totalWeightedWinRate / totalGames : 0;
    }

    // Function to fetch card ratings data from 17lands for a specific expansion and merge
    async function fetchCardRatings(targetExpansion) {
        if (!targetExpansion) return null;
        if (queriedExpansions.has(targetExpansion)) {
            return null;
        }
        if (fetchPromisesByExpansion[targetExpansion]) {
            return await fetchPromisesByExpansion[targetExpansion];
        }

        // Track global request state
        activeFetchCount += 1;
        isRequestInProgress = activeFetchCount > 0;

        try {
            // console.log(`📊 Fetching card ratings from 17lands for expansion: ${targetExpansion}`);
            const suffix = targetExpansion == 'DFT' ? '&start_date=2025-02-11&end_date=2025-08-19' : '';
            const url = `https://www.17lands.com/card_ratings/data?expansion=${targetExpansion}&event_type=PremierDraft${suffix}`;
            const fetchPromise = fetch(url).then(async (response) => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                const expansionData = await response.json();
                // Merge entries into list and maps (by mtga_id if available, otherwise by name)
                expansionData.forEach(entry => {
                    if (!entry || !entry.name) return;
                    const id = entry.mtga_id;
                    let existing = null;
                    if (id != null && cardRatingsByMtgaId[id]) {
                        existing = cardRatingsByMtgaId[id];
                    } else if (cardRatingsByName[entry.name]) {
                        existing = cardRatingsByName[entry.name];
                    }

                    if (existing) {
                        // Update existing entry in-place
                        Object.assign(existing, entry);
                        // Ensure maps are synced
                        cardRatingsByName[existing.name] = existing;
                        if (existing.mtga_id != null) {
                            cardRatingsByMtgaId[existing.mtga_id] = existing;
                        }
                    } else {
                        cardRatingsList.push(entry);
                        cardRatingsByName[entry.name] = entry;
                        if (id != null) {
                            cardRatingsByMtgaId[id] = entry;
                        }
                    }
                });
                queriedExpansions.add(targetExpansion);
                return expansionData;
            });

            fetchPromisesByExpansion[targetExpansion] = fetchPromise;
            const result = await fetchPromise;
            return result;
        } catch (error) {
            console.error('❌ Error fetching card ratings:', error);
            return null;
        } finally {
            // Clear in-flight promise and update request tracking
            delete fetchPromisesByExpansion[targetExpansion];
            activeFetchCount = Math.max(0, activeFetchCount - 1);
            isRequestInProgress = activeFetchCount > 0;
        }
    }

    // Function to find card rating by mtga_id (arena_id) first, then by name
    function findCardRating(card) {
        if (!card) return null;
        if (card.arena_id != null && cardRatingsByMtgaId[card.arena_id]) {
            return cardRatingsByMtgaId[card.arena_id];
        }
        if (cardRatingsByName[card.name]) {
            return cardRatingsByName[card.name];
        }
        return null;
    }

    // Function to get color based on delta winrate
    function getColorForDelta(deltaWinrate) {
        // Normalize delta to 0-1 range for color calculation
        const normalizedDelta = Math.max(-0.05, Math.min(0.05, deltaWinrate));
        const normalizedValue = (normalizedDelta + 0.05) / 0.1; // Convert to 0-1 range
        
        let r, g, b;
        
        if (normalizedValue >= 0.5) {
            // yellow to green transition (0.5 to 1.0)
            const factor = (normalizedValue - 0.5) * 2; // 0 to 1
            r = Math.round(192 * (1-factor));
            g = Math.round(128 * (1+factor) - 1); 
            b = Math.round(32 * (1+factor) - 1);
        } else {
            // red to yellow transition (0.0 to 0.5)
            const factor = normalizedValue * 2; // 0 to 1
            r = Math.round(64 * (3+factor) - 1);
            g = Math.round(127 * factor);
            b = 0;
        }
        
        return `rgba(${r}, ${g}, ${b}, 0.8)`;
    }

    // Function to add delta winrate overlays above each card
    function addDeltaWinrateOverlays(boosterCards) {
        // Extract current card names for comparison
        const currentCardNames = boosterCards.map(card => card.props.card.name);

        // Check if card list has changed
        if (currentCardNames.length === 0) {
            console.log('⚠️ No cards found, skipping overlay refresh');
            return;
        }

        // Check if the card list is the same as last time and overlays already present
        const existingOverlaysForSkipCheck = document.querySelectorAll('.card-name-overlay');
        const isSameCardList = lastCardNames.length === currentCardNames.length &&
            lastCardNames.every((name, index) => name === currentCardNames[index]);
        if (isSameCardList && existingOverlaysForSkipCheck.length === currentCardNames.length) {
            // console.log('🔄 Card list unchanged and overlays present, skipping overlay refresh');
            return;
        }

        // console.log('🎨 Adding delta winrate overlays...');
        // console.log(`📊 Cards changed from [${lastCardNames.join(', ')}] to [${currentCardNames.join(', ')}]`);

        // Update last known card names
        lastCardNames = [...currentCardNames];

        // Remove any existing overlays first
        const existingOverlays = document.querySelectorAll('.card-name-overlay');
        existingOverlays.forEach(overlay => overlay.remove());

        // Try to match Vue components with DOM elements
        boosterCards.forEach((card, index) => {
            const cardData = card.props.card;
            let element = card.el;

            if (!element) {
                console.log(`❌ No DOM element found for card: ${cardData.name}`);
                return;
            }

            // Find the card rating
            const rating = findCardRating(cardData);
            let displayText = cardData.name;
            let backgroundColor = 'rgba(0, 0, 0, 0.8)';

            if (!rating) {
                console.log(`⚠️ No rating data found for: ${cardData.name}`);
                return;
            }
            const cardWinRate = rating.ever_drawn_win_rate;
            const averageWinRate = 0.55; // hard code to allow multi pack scenario
            const deltaWinrate = cardWinRate - averageWinRate;
            const percentage = (deltaWinrate * 100).toFixed(1);
            displayText = deltaWinrate >= 0 ? `+${percentage}` : `${percentage}`;

            // Use gradual color transition based on delta winrate
            backgroundColor = getColorForDelta(deltaWinrate);

            // Create overlay element
            const overlay = document.createElement('div');
            overlay.className = 'card-name-overlay';
            overlay.textContent = displayText;
            overlay.title = `${cardData.name}: ${displayText}`;
            overlay.style.cssText = `
                position: absolute;
                top: 20%;
                right: 5px;
                background: ${backgroundColor};
                color: white;
                padding: 4px 8px;
                border-radius: 4px;
                font-size: 12px;
                font-weight: bold;
                white-space: nowrap;
                z-index: 1000;
                pointer-events: none;
                text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
                cursor: help;
            `;

            // Append the overlay directly to the card element itself
            element.appendChild(overlay);

            // console.log(`✅ Added overlay for "${cardData.name}" (${displayText}) to element:`, element);
        });
    }

    // Function to start monitoring for changes
    function startMonitoring() {
        if (refreshInterval) {
            clearInterval(refreshInterval);
        }

        // Monitor for changes in the main content area
        const targetNode = document.querySelector('.main-content') || document.body;

        refreshInterval = setInterval(() => {
            // Only run if no loop is currently in progress
            if (!isLoopInProgress) {
                refreshOverlaysLoop();
            } else {
                // console.log('⏳ Skipping interval - loop in progress');
            }
        }, 1000); // Check every 1000ms

        // console.log('👀 Started monitoring for card changes');
    }

    // Synchronous function to find and return BoosterCard instances using Vue 3 app structure
    function findBoosterCards() {
        try {
            // console.log('🔍 Locating BoosterCard instances using Vue 3 app structure...');

            if (!document.querySelector('.booster.card-container')) { // game has not started yet
                return [];
            }
            // Find the Vue 3 app instance
            const appElement = Array.from(document.querySelectorAll('*')).find((e) => e.__vue_app__);
            if (!appElement) {
                // console.log('❌ No Vue 3 app found');
                return [];
            }

            const app = appElement.__vue_app__;
            // console.log('✅ Vue 3 app found:', app);

            // Check if BoosterCard component exists
            if (!app._component.components.BoosterCard) {
                // console.log('❌ BoosterCard component not found in app._component.components');
                // console.log('Available components:', Object.keys(app._component.components));
                return [];
            }

            // Navigate through the component tree as described
            let currentNode = app._container._vnode.component.subTree;
            if (!currentNode) {
                // console.log('❌ Could not access app._container._vnode.component.subTree');
                return [];
            }

            // Find main-content
            const mainContentNode = currentNode.children?.find(child =>
                child.props?.class === 'main-content'
            );

            if (!mainContentNode) {
                // console.log('❌ Could not find main-content node');
                // console.log('Available children:', currentNode.children);
                return [];
            }

            // Find generic-container
            const genericContainerNode = mainContentNode.children?.find(child =>
                child.props?.class === 'generic-container'
            );

            if (!genericContainerNode) {
                // console.log('❌ Could not find generic-container node');
                // console.log('Available children:', mainContentNode.children);
                return [];
            }

            // Find node with type == 'Symbol(v-fgt)'
            const vFgtNode = genericContainerNode.children?.find(child =>
                child.type && child.type.toString() === 'Symbol(v-fgt)'
            );

            if (!vFgtNode) {
                // console.log('❌ Could not find v-fgt node');
                // console.log('Available children:', genericContainerNode.children);
                return [];
            }

            // Find transition node and navigate to draft-picking container
            const draftPickingNode = vFgtNode.children?.find(child => {
                return child.el && child.el.nodeName === 'DIV'
            });

            if (!draftPickingNode) {
                // console.log('❌ Could not find draft-picking container node');
                // console.log('Available v-fgt children:', vFgtNode.children);
                return [];
            }

            // Get the actual draft-picking node from the component tree
            const actualDraftPickingNode = draftPickingNode.component.subTree.component.subTree;

            // Find booster-cards TransitionGroup
            const boosterCardsNode = actualDraftPickingNode.children?.find(child =>
                child.type?.name === 'TransitionGroup'
            );

            if (!boosterCardsNode) {
                // console.log('❌ Could not find booster-cards TransitionGroup');
                // console.log('Available draft-picking children:', actualDraftPickingNode.children);
                return [];
            }

            // Get the list of BoosterCards
            if (boosterCardsNode.component?.subTree?.children) {
                const boosterCards = boosterCardsNode.component.subTree.children;
                return boosterCards || [];
            } else {
                // console.log('❌ Could not access booster-cards children');
                return [];
            }

        } catch (error) {
            console.error('❌ Error while locating BoosterCards:', error);
            return [];
        }
    }

    // Main loop to: prefetch related data, then render overlays
    async function refreshOverlaysLoop() {
        if (isLoopInProgress) return;
        isLoopInProgress = true;
        try {
            // Prefetch based on detected expansion if possible (best-effort)
            const detected = detectExpansion();
            if (detected && !queriedExpansions.has(detected)) {
                await fetchCardRatings(detected);
            }

            const boosterCards = findBoosterCards();
            if (!boosterCards || boosterCards.length === 0) {
                return;
            }

            // Determine which expansions to fetch based on the sets of visible cards
            const expansionsToFetch = new Set();
            boosterCards.forEach(cardNode => {
                const card = cardNode?.props?.card;
                if (!card) return;
                const hasRating = (card.arena_id != null && cardRatingsByMtgaId[card.arena_id]) || cardRatingsByName[card.name];
                if (hasRating) return;
                const inferred = inferExpansionsFromSetName(card.set);
                inferred.forEach(exp => {
                    if (!queriedExpansions.has(exp)) {
                        expansionsToFetch.add(exp);
                    }
                });
            });

            if (expansionsToFetch.size > 0) {
                await Promise.all(Array.from(expansionsToFetch).map(exp => fetchCardRatings(exp)));
            }

            // Now render overlays
            addDeltaWinrateOverlays(boosterCards);
        } catch (error) {
            console.error('❌ Error in refreshOverlaysLoop:', error);
        } finally {
            isLoopInProgress = false;
        }
    }

    // Register functions to window for easy access
    window.findDraftmancerBoosterCards = findBoosterCards;
    window.addDeltaWinrateOverlays = addDeltaWinrateOverlays;
    window.fetchCardRatings = fetchCardRatings;
    window.startMonitoring = startMonitoring;
    window.refreshOverlaysLoop = refreshOverlaysLoop;
    window.stopMonitoring = () => {
        if (refreshInterval) {
            clearInterval(refreshInterval);
            refreshInterval = null;
            console.log('⏹️ Stopped monitoring for card changes');
        }
    };
    // Auto-run the search after a delay to allow Vue to initialize
    setTimeout(() => {
        console.log('🚀 Draftmancer BoosterCard Inspector script loaded!');
        console.log('💡 Use window.findDraftmancerBoosterCards() to get BoosterCards');
        console.log('💡 Use window.addDeltaWinrateOverlays(boosterCards) to add visual overlays');
        console.log('💡 Use window.fetchCardRatings(expansion) to fetch 17lands data for a specific expansion');
        console.log('💡 Use window.startMonitoring() to start monitoring for changes');
        console.log('💡 Auto-running monitor in 1 second...');

        setTimeout(() => {
            refreshOverlaysLoop();
            startMonitoring();
        }, 1000);
    }, 100);

})();