BGA Flip Seven Card Counter

Card counter for Flip Seven on BoardGameArena

// ==UserScript==
// @name         BGA Flip Seven Card Counter
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Card counter for Flip Seven on BoardGameArena
// @author       KuRRe8
// @match        https://boardgamearena.com/*/flipseven?table=*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    function isInGameUrl(url) {
        return /https:\/\/boardgamearena\.com\/\d+\/flipseven\?table=\d+/.test(url);
    }

    // Card counting data initialization
    function getInitialCardDict() {
        return {
            '12card': 12,
            '11card': 11,
            '10card': 10,
            '9card': 9,
            '8card': 8,
            '7card': 7,
            '6card': 6,
            '5card': 5,
            '4card': 4,
            '3card': 3,
            '2card': 2,
            '1card': 1,
            '0card': 1,
            'flip3': 3,
            'Second chance': 3,
            'Freeze': 3,
            'Plus2': 1,
            'Plus4': 1,
            'Plus6': 1,
            'Plus8': 1,
            'Plus10': 1,
            'double': 1
        };
    }

    let cardDict = null;
    let roundCardDict = null; // Current round card counting data
    let playerBoardDict = null; // All players' board cards, array, each element is a player's card object
    let busted_players = {};

    function getInitialPlayerBoardDict() {
        // Same structure as cardDict, all values initialized to 0
        return Object.fromEntries(Object.keys(getInitialCardDict()).map(k => [k, 0]));
    }

    function clearPlayerBoardDict(idx) {
        // idx: optional, specify player index, if not provided, clear all
        if (Array.isArray(playerBoardDict)) {
            if (typeof idx === 'number') {
                Object.keys(playerBoardDict[idx]).forEach(k => playerBoardDict[idx][k] = 0);
                console.log(`[Flip Seven Counter] Player ${idx+1} board cleared`, playerBoardDict[idx]);
            } else {
                playerBoardDict.forEach((dict, i) => {
                    Object.keys(dict).forEach(k => dict[k] = 0);
                });
                console.log('[Flip Seven Counter] All players board cleared', playerBoardDict);
            }
        }
    }

    function clearRoundCardDict() {
        if (roundCardDict) {
            Object.keys(roundCardDict).forEach(k => roundCardDict[k] = 0);
            console.log('[Flip Seven Counter] Round card data cleared', roundCardDict);
        }
    }

    function resetBustedPlayers() {
        const playerNames = window.flipsevenPlayerNames || [];
        busted_players = {};
        playerNames.forEach(name => {
            busted_players[name] = false;
        });
    }

    function createCardCounterPanel() {
        // Create floating panel
        let panel = document.createElement('div');
        panel.id = 'flipseven-card-counter-panel';
        panel.style.position = 'fixed';
        panel.style.top = '80px';
        panel.style.right = '20px';
        panel.style.zIndex = '99999';
        panel.style.background = 'rgba(173, 216, 230, 0.85)'; // light blue semi-transparent
        panel.style.border = '1px solid #5bb';
        panel.style.borderRadius = '8px';
        panel.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
        panel.style.padding = '12px 16px';
        panel.style.fontSize = '15px';
        panel.style.color = '#222';
        panel.style.maxHeight = '80vh';
        panel.style.overflowY = 'auto';
        panel.style.minWidth = '180px';
        panel.style.userSelect = 'text';
        panel.style.cursor = 'move'; // draggable cursor
        panel.innerHTML = '<b>Flip Seven Counter</b><hr style="margin:6px 0;">' +
            renderCardDictTable(cardDict) +
            '<div style="height:18px;"></div>' +
            '<div style="font-size: 1.5em; font-weight: bold; text-align:left;">rate <span style="float:right;">100%</span></div>';
        document.body.appendChild(panel);
        makePanelDraggable(panel);
    }


    function getPlayerSafeRate(idx) {
        // Calculate the safe card probability for a specific player
        let safe = 0, total = 0;
        if (!playerBoardDict) return -1;
        if (!playerBoardDict[idx]) return -2;
        for (const k in cardDict) {
            if (playerBoardDict[idx][k] === 0) {
                safe += cardDict[k];
            }
            total += cardDict[k];
        }
        if (total === 0) return -3;
        return Math.round((safe / total) * 100);
    }

    function updateCardCounterPanel(flashKey) {
        const panel = document.getElementById('flipseven-card-counter-panel');
        if (panel) {
            const playerNames = window.flipsevenPlayerNames || [];
            let namesHtml = playerNames.map((n, idx) => {
                let shortName = n.length > 10 ? n.slice(0, 10) : n;
                if (busted_players[n]) {
                    return `<div style=\"margin-bottom:2px;\"><span style=\"display:inline-block;max-width:6em;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;\">${shortName}</span> <span style='color:#888;font-size:0.95em;'>Busted</span></div>`;
                } else {
                    let rate = getPlayerSafeRate(idx);
                    let rateColor = '#888';
                    if (rate < 30) rateColor = '#b94a48';
                    else if (rate < 50) rateColor = '#bfae3b';
                    else rateColor = '#4a7b5b';
                    return `<div style=\"margin-bottom:2px;\"><span style=\"display:inline-block;max-width:6em;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;\">${shortName}</span> <span style='color:${rateColor};font-size:0.95em;'>${rate}%</span></div>`;
                }
            }).join('');
            panel.innerHTML = '<b>Flip Seven Counter</b><hr style="margin:6px 0;">' +
                renderCardDictTable(cardDict) +
                '<div style="height:18px;"></div>' +
                `<div style="font-size: 1.2em; font-weight: bold; text-align:left;">${namesHtml}</div>`;
            if (flashKey) flashNumberCell(flashKey);
        }
    }

    // Draggable panel functionality
    function makePanelDraggable(panel) {
        let isDragging = false;
        let offsetX = 0, offsetY = 0;
        panel.addEventListener('mousedown', function(e) {
            isDragging = true;
            offsetX = e.clientX - panel.getBoundingClientRect().left;
            offsetY = e.clientY - panel.getBoundingClientRect().top;
            document.body.style.userSelect = 'none';
        });
        document.addEventListener('mousemove', function(e) {
            if (isDragging) {
                panel.style.left = (e.clientX - offsetX) + 'px';
                panel.style.top = (e.clientY - offsetY) + 'px';
                panel.style.right = '';
            }
        });
        document.addEventListener('mouseup', function() {
            isDragging = false;
            document.body.style.userSelect = '';
        });
    }

    function renderCardDictTable(dict) {
        let html = '<table style="border-collapse:collapse;width:100%;">';
        const totalLeft = Object.values(dict).reduce((a, b) => a + b, 0) || 1;
        for (const [k, v] of Object.entries(dict)) {
            const percent = Math.round((v / totalLeft) * 100);

            const percentColor = '#888';

            let numColor = '#888';
            if (v === 1 || v === 2) numColor = '#2ecc40';
            else if (v >= 3 && v <= 5) numColor = '#ffdc00';
            else if (v > 5) numColor = '#ff4136';
            html += `<tr><td style='padding:2px 6px;'>${k}</td><td class='flipseven-anim-num' data-key='${k}' style='padding:2px 6px;text-align:right;color:${numColor};font-weight:bold;'>${v} <span style='font-size:0.9em;color:${percentColor};'>(${percent}%)</span></td></tr>`;
        }
        html += '</table>';
        return html;
    }

    function flashNumberCell(key) {
        const cell = document.querySelector(`#flipseven-card-counter-panel .flipseven-anim-num[data-key='${key}']`);
        if (cell) {
            cell.style.transition = 'background 0.2s';
            cell.style.background = '#fff7b2';
            setTimeout(() => {
                cell.style.background = '';
            }, 200);
        }
    }

    function updatePlayerBoardDictFromDOM() {
        // Get player count
        const playerNames = window.flipsevenPlayerNames || [];
        const playerCount = playerNames.length;
        // Process each player
        for (let i = 0; i < playerCount; i++) {
            const container = document.querySelector(`#app > div > div > div.f7_scalable.f7_scalable_zoom > div > div.f7_players_container > div:nth-child(${i+1}) > div:nth-child(3)`);
            if (!container) {
                console.warn(`[Flip Seven Counter] Player ${i+1} board container not found`);
                continue;
            }
            // Clear this player's stats
            clearPlayerBoardDict(i);
            // Count all cards
            const cardDivs = container.querySelectorAll('.flippable-front');
            cardDivs.forEach(frontDiv => {
                // class like 'flippable-front sprite sprite-c8', get the number
                const classList = frontDiv.className.split(' ');
                const spriteClass = classList.find(cls => cls.startsWith('sprite-c'));
                if (spriteClass) {
                    const num = spriteClass.replace('sprite-c', '');
                    if (/^\d+$/.test(num)) {
                        const key = num + 'card';
                        if (playerBoardDict[i].hasOwnProperty(key)) {
                            playerBoardDict[i][key] += 1;
                        }
                    }
                }
            });
            // console.log(`[Flip Seven Counter] Player ${i+1} board:`, JSON.parse(JSON.stringify(playerBoardDict[i])));
        }
    }

    // Periodic event: check every 300ms
    function startPlayerBoardMonitor() {
        setInterval(updatePlayerBoardDictFromDOM, 300);
    }

    // Log monitor
    let lc = 0; // log counter
    function startLogMonitor() {
        let lastLogInfo = null; // 记录上一次log的有用信息 {playerName, cardKey}
        setInterval(() => {
            const logElem = document.getElementById('log_' + lc);
            if (!logElem) return; // No such log, wait for next
            // Check for new round
            const firstDiv = logElem.querySelector('div');
            if (firstDiv && firstDiv.innerText && firstDiv.innerText.trim().includes('新的一轮')) {
                clearRoundCardDict();
                resetBustedPlayers();
                updateCardCounterPanel();
                lc++;
                return;
            }
            if (firstDiv && firstDiv.innerText && firstDiv.innerText.includes('弃牌堆洗牌')) {
                cardDict = getInitialCardDict();
                for (const k in roundCardDict) {
                    if (cardDict.hasOwnProperty(k)) {
                        cardDict[k] = Math.max(0, cardDict[k] - roundCardDict[k]);
                    }
                }
                updateCardCounterPanel();
                lc++;
                return;
            }
            if (firstDiv && firstDiv.innerText && firstDiv.innerText.includes('爆牌')) {
                // 查找 span.playername
                const nameSpan = firstDiv.querySelector('span.playername');
                if (nameSpan) {
                    const bustedName = nameSpan.innerText.trim();
                    if (busted_players.hasOwnProperty(bustedName)) {
                        busted_players[bustedName] = true;
                        updateCardCounterPanel();
                    }
                }
            }
            if (firstDiv && firstDiv.innerText && firstDiv.innerText.includes('第二次机会') && firstDiv.innerText.includes('卡牌被弃除')) {
                if (cardDict['Second chance'] > 0) {
                    cardDict['Second chance']--;
                    console.log('[Flip Seven Counter] "第二次机会"卡牌被弃除,cardDict[Second chance]--,当前剩余:', cardDict['Second chance']);
                    updateCardCounterPanel('Second chance');
                }
            }
            
            if (firstDiv && firstDiv.innerText && firstDiv.innerText.includes('放弃“第二次机会”以及他们刚抽到的牌')) {
                if (lastLogInfo && lastLogInfo.cardKey) {
                    if (roundCardDict && roundCardDict[lastLogInfo.cardKey] > 0) {
                        roundCardDict[lastLogInfo.cardKey]--;
                        console.log(`[Flip Seven Counter] LogicA: ${lastLogInfo.cardKey}从roundCardDict中剔除`);
                    }
                    if (roundCardDict && roundCardDict['Second chance'] > 0) {
                        roundCardDict['Second chance']--;
                        console.log('[Flip Seven Counter] LogicA: 剔除一张Second chance卡 from roundCardDict, 剩余:', roundCardDict['Second chance']);
                    }
                    updateCardCounterPanel(lastLogInfo.cardKey);
                }
            }
            // Check for card type
            const cardElem = logElem.querySelector('.visible_flippable.f7_token_card.f7_logs');
            if (!cardElem) {
                lc++;
                return; // No card, skip
            }
            // Find the only child div's only child div
            let frontDiv = cardElem;
            frontDiv = frontDiv.children[0];
            frontDiv = frontDiv.children[0];
            if (!frontDiv || !frontDiv.className) {
                lc++;
                return;
            }
            // Parse className
            const classList = frontDiv.className.split(' ');
            const spriteClass = classList.find(cls => cls.startsWith('sprite-'));
            if (!spriteClass) {
                lc++;
                return;
            }
            // Handle number cards
            let key = null;
            if (/^sprite-c(\d+)$/.test(spriteClass)) {
                const num = spriteClass.match(/^sprite-c(\d+)$/)[1];
                key = num + 'card';
            } else if (/^sprite-s(\d+)$/.test(spriteClass)) {
                // Plus2/4/6/8/10
                const num = spriteClass.match(/^sprite-s(\d+)$/)[1];
                key = 'Plus' + num;
            } else if (spriteClass === 'sprite-sf') {
                key = 'Freeze';
            } else if (spriteClass === 'sprite-sch') {
                key = 'Second chance';
            } else if (spriteClass === 'sprite-sf3') {
                key = 'flip3';
            } else if (spriteClass === 'sprite-sx2') {
                key = 'double';
            }
            let playerName = null;
            const nameSpan = firstDiv.querySelector && firstDiv.querySelector('span.playername');
            if (nameSpan) {
                playerName = nameSpan.innerText.trim();
            }
            if (playerName && key) {
                lastLogInfo = { playerName, cardKey: key };
            }
            if (key && cardDict.hasOwnProperty(key) && roundCardDict.hasOwnProperty(key)) {
                if (cardDict[key] > 0) cardDict[key]--;
                roundCardDict[key]++;
                console.log(`[Flip Seven Counter] log_${lc} found ${key}, global left ${cardDict[key]}, round used ${roundCardDict[key]}`);
                updateCardCounterPanel(key);
            } else {
                console.log(`[Flip Seven Counter] log_${lc} unknown card type`, spriteClass);
            }
            lc++;
        }, 200);
    }

    function initializeGame() {
        cardDict = getInitialCardDict();
        roundCardDict = Object.fromEntries(Object.keys(cardDict).map(k => [k, 0]));
        playerBoardDict = Array.from({length: 12}, () => getInitialPlayerBoardDict());
        resetBustedPlayers();
        console.log('[Flip Seven Counter] Card data initialized', cardDict);
        console.log('[Flip Seven Counter] Round card data initialized', roundCardDict);
        console.log('[Flip Seven Counter] All players board initialized', playerBoardDict);
        createCardCounterPanel();
        startPlayerBoardMonitor();
        startLogMonitor();
        // You can continue to extend initialization logic here
    }

    function runLogic() {
        setTimeout(() => {
            // Detect all player names
            let playerNames = [];
            for (let i = 1; i <= 12; i++) {
                const selector = `#app > div > div > div.f7_scalable.f7_scalable_zoom > div > div.f7_players_container > div:nth-child(${i}) > div.f7_player_name.flex.justify-between > div:nth-child(1)`;
                const nameElem = document.querySelector(selector);
                if (nameElem && nameElem.innerText.trim()) {
                    playerNames.push(nameElem.innerText.trim());
                } else {
                    break;
                }
            }
            alert(`[Flip Seven Counter] Entered game room. Player list:\n` + playerNames.map((n, idx) => `${idx+1}. ${n}`).join('\n'));
            window.flipsevenPlayerNames = playerNames; // global access
            initializeGame();
            // You can continue your logic here
        }, 1500);
    }

    // First enter page
    if (isInGameUrl(window.location.href)) {
        runLogic();
    }

    // Listen for SPA navigation
    function onUrlChange() {
        if (isInGameUrl(window.location.href)) {
            runLogic();
        }
    }
    const _pushState = history.pushState;
    const _replaceState = history.replaceState;
    history.pushState = function() {
        _pushState.apply(this, arguments);
        setTimeout(onUrlChange, 0);
    };
    history.replaceState = function() {
        _replaceState.apply(this, arguments);
        setTimeout(onUrlChange, 0);
    };
    window.addEventListener('popstate', onUrlChange);

})();