Chess.com Bot — Improved (Top 3 Moves & Threats)

Shows Stockfish's top 3 moves & threats on Chess.com vs computer, with persistent settings, depth slider, safer engine handling.

目前為 2025-09-27 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Chess.com Bot — Improved (Top 3 Moves & Threats)
// @namespace    thehackerclient
// @version      2.1
// @description  Shows Stockfish's top 3 moves & threats on Chess.com vs computer, with persistent settings, depth slider, safer engine handling.
// @author       thehackerclient
// @match        https://www.chess.com/*
// @icon         https://www.chess.com/bundles/web/images/favicon.ico
// @grant        GM_getResourceText
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @resource     stockfish.js https://cdnjs.cloudflare.com/ajax/libs/stockfish.js/9.0.0/stockfish.js
// @license      MIT
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    // --------- Config & state ---------
    const STORAGE_KEY = 'chess_bot_settings_v2';
    const DEFAULTS = { autoRun: true, autoMovePiece: false, delay: 1, lastDepth: 18, showPV: true, colors: { move1: 'rgba(235,97,80,0.7)', move2:'rgba(255,165,0,0.6)', move3:'rgba(255,255,0,0.5)', threat:'rgba(0,128,255,0.35)' }, highlightMs: 1400 };
    let settings = Object.assign({}, DEFAULTS, loadSettings());

    let board = null;                    // DOM element for chess-board / wc-chess-board
    let engine = { worker: null };       // stockfish worker wrapper
    let stockfishObjectURL = null;
    let candidateMoves = [];             // [{move:'e2e4', score:120, depth:... , pv:[] }]
    let isThinking = false;
    let canGo = true;
    let lastFen = '';

    // debounce/throttle helpers
    function debounce(fn, wait){ let t; return function(...a){ clearTimeout(t); t = setTimeout(()=>fn.apply(this,a), wait); }; }
    function throttle(fn, wait){ let last=0; return function(...a){ const now=Date.now(); if(now-last>wait){ last=now; fn.apply(this,a);} }; }

    // safer query for board element — supports both modern and older chess.com tags
    function findBoard(){ return $('chess-board')[0] || $('wc-chess-board')[0] || document.querySelector('[data-cy="board"]') || null; }

    // --------- Persistence ---------
    function loadSettings(){ try{ const raw = localStorage.getItem(STORAGE_KEY); return raw? JSON.parse(raw): {}; }catch(e){ return {}; } }
    function saveSettings(){ try{ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); }catch(e){} }

    // --------- Stockfish lifecycle ---------
    function createStockfishWorker(){
        try{
            if(stockfishObjectURL === null){
                const text = GM_getResourceText('stockfish.js');
                stockfishObjectURL = URL.createObjectURL(new Blob([text], {type:'application/javascript'}));
            }
            if(engine.worker) engine.worker.terminate();
            engine.worker = new Worker(stockfishObjectURL);
            engine.worker.onmessage = e => handleEngineMessage(e.data);
            engine.worker.postMessage('ucinewgame');
            console.log('Stockfish worker created');
        }catch(err){ console.error('Failed to create Stockfish worker', err); }
    }

    function safeRestartEngine(){ isThinking = false; try{ if(engine.worker) engine.worker.terminate(); }catch(e){} createStockfishWorker(); }

    // --------- Engine message parsing ---------
    function parseScore(match){
        if(!match) return 0;
        const type = match[1];
        const val = parseInt(match[2]);
        if(type === 'cp') return val;
        // mate scores: convert to a large cp-ish value with sign
        return val > 0 ? 100000 - val : -100000 - val; // preserves mate signs and ordering
    }

    function handleEngineMessage(msg){
        if(typeof msg !== 'string') return;
        // collect candidate PV info from 'info' lines
        if(msg.startsWith('info') && msg.includes('pv')){
            const pvTokens = msg.split(' pv ')[1].trim().split(/\s+/);
            if(pvTokens && pvTokens.length){
                const move = pvTokens[0];
                const scoreMatch = msg.match(/score (cp|mate) (-?\d+)/);
                const score = parseScore(scoreMatch);
                const depthMatch = msg.match(/depth (\d+)/);
                const depth = depthMatch? parseInt(depthMatch[1]) : settings.lastDepth;

                // avoid duplicates — update existing if higher depth/score
                const exists = candidateMoves.find(c=>c.move===move);
                if(!exists) candidateMoves.push({move, score, depth, pv: pvTokens});
                else if(depth>exists.depth) { exists.score=score; exists.depth=depth; exists.pv=pvTokens; }
            }
        }

        if(msg.startsWith('bestmove')){
            // finalize list, sort and keep top 3
            candidateMoves.sort((a,b) => b.score - a.score);
            candidateMoves = candidateMoves.slice(0,3);
            showTopMoves();
            const move = msg.split(' ')[1];
            if(settings.autoMovePiece && move) performMove(move);
            isThinking = false;
        }
    }

    // --------- Utilities ---------
    function mapSquareForBoard(sq){
        if(!board || !sq || sq.length<2) return sq;
        const isFlipped = board.classList && board.classList.contains('flipped');
        if(!isFlipped) return sq;
        const file = sq[0];
        const rank = sq[1];
        const flippedFile = String.fromCharCode('h'.charCodeAt(0) - (file.charCodeAt(0)-'a'.charCodeAt(0)));
        const flippedRank = (9 - parseInt(rank)).toString();
        return flippedFile + flippedRank;
    }

    function getBoardSquareEl(sq){ try{ return board.querySelector(`[data-square="${sq}"]`); }catch(e){ return null; } }

    // create or reuse overlay element on a square
    function attachHighlight(el, cls, color){
        if(!el) return null;
        let overlay = el.querySelector('.' + cls);
        if(!overlay){ overlay = document.createElement('div'); overlay.className = cls; overlay.style.position='absolute'; overlay.style.top=0; overlay.style.left=0; overlay.style.width='100%'; overlay.style.height='100%'; overlay.style.pointerEvents='none'; overlay.style.zIndex=60; el.appendChild(overlay); }
        overlay.style.backgroundColor = color;
        return overlay;
    }

    function detachHighlights(selector){ try{ document.querySelectorAll(selector).forEach(n=>n.parentElement && n.parentElement.removeChild(n)); }catch(e){} }

    // --------- Highlighting & UI ---------
    function showTopMoves(){
        if(!board || !board.game) return;
        detachHighlights('.botMoveHighlight');
        detachHighlights('.botThreatHighlight');

        candidateMoves.forEach((cm, i) => {
            const from = mapSquareForBoard(cm.move.slice(0,2));
            const to = mapSquareForBoard(cm.move.slice(2,4));
            const color = i===0? settings.colors.move1 : (i===1? settings.colors.move2 : settings.colors.move3);

            [from, to].forEach(sq => {
                const el = getBoardSquareEl(sq);
                if(el) {
                    const ov = attachHighlight(el, 'botMoveHighlight', color);
                    // remove after highlightMs
                    setTimeout(()=>{ if(ov && ov.parentElement) ov.parentElement.removeChild(ov); }, settings.highlightMs);
                }
            });

            // optionally show PV text in GUI (we add a small floating element on top-right of board)
            if(settings.showPV && cm.pv && cm.pv.length){
                addPVNote(cm, i);
            }
        });
        showThreats();
    }

    function addPVNote(cm, index){
        try{
            const id = `pvNote-${index}`;
            let note = document.getElementById(id);
            if(!note){ note = document.createElement('div'); note.id=id; note.style.position='absolute'; note.style.right='6px'; note.style.top=(6 + index*28)+'px'; note.style.padding='6px 8px'; note.style.borderRadius='6px'; note.style.background='rgba(0,0,0,0.6)'; note.style.color='#fff'; note.style.zIndex=120; note.style.fontSize='12px'; board.parentElement.appendChild(note); }
            note.innerText = `#${index+1} ${cm.move} (${Math.round(cm.score)}) PV: ${cm.pv.slice(0,6).join(' ')}`;
            setTimeout(()=>{ if(note && note.parentElement) note.parentElement.removeChild(note); }, settings.highlightMs + 5000);
        }catch(e){}
    }

    function showThreats(){
        if(!board || !board.game) return;
        try{
            const legalMoves = board.game.getLegalMoves();
            const opponent = board.game.getTurn() === 'w' ? 'b' : 'w';
            legalMoves.forEach(m=>{ if(m.color===opponent){ const sq = mapSquareForBoard(m.to); const el = getBoardSquareEl(sq); if(el){ const ov = attachHighlight(el, 'botThreatHighlight', settings.colors.threat); setTimeout(()=>{ if(ov && ov.parentElement) ov.parentElement.removeChild(ov); }, settings.highlightMs); } } });
        }catch(e){ console.warn('Failed to show threats', e); }
    }

    // performs move using board.game.move (handles promotion if UCI includes promotion)
    function performMove(moveUCI){
        if(!board || !board.game) return;
        try{
            // moveUCI might be like e7e8q for promotion; board.game expects objects from getLegalMoves
            const from = moveUCI.slice(0,2); const to = moveUCI.slice(2,4); const promotion = moveUCI.length>4? moveUCI[4] : null;
            const legal = board.game.getLegalMoves();
            for(const m of legal){ if(m.from===from && m.to===to){
                // if promotion required, check piece
                if(m.promotion && promotion){ m.promotion = promotion; }
                board.game.move(Object.assign({}, m, {animate:false, userGenerated:true}));
                break;
            }}
        }catch(e){ console.error('performMove failed', e); }
    }

    // --------- Engine runner & controls ---------
    function runChessEngine(depth){
        if(!board || !engine.worker || !board.game) return;
        try{
            const fen = board.game.getFEN();
            // don't re-run if same fen and same depth and already thinking
            if(isThinking && fen === lastFen && depth===settings.lastDepth) return;
            lastFen = fen;
            candidateMoves = [];
            engine.worker.postMessage('position fen ' + fen);
            isThinking = true;
            engine.worker.postMessage('go depth ' + depth);
        }catch(e){ console.error('runChessEngine error', e); }
    }

    // debounce auto-run so rapid UI events don't spam engine
    const debouncedRun = debounce((d)=> runChessEngine(d), 300);

    // auto-run loop — throttled to avoid spamming
    const autoLoop = throttle(()=>{
        if(!board || !board.game) return;
        if(settings.autoRun && canGo && !isThinking && board.game.getTurn() === board.game.getPlayingAs()){
            canGo = false;
            setTimeout(()=>{ debouncedRun(settings.lastDepth); canGo = true; }, Math.max(200, settings.delay*1000));
        }
    }, 200);

    // --------- GUI ---------
    function initGUI(){
        board = findBoard();
        if(!board) return false;
        if(document.getElementById('botGUI_v2')) return true;

        const container = document.createElement('div');
        container.id = 'botGUI_v2';
        container.style = 'background:rgba(255,255,255,0.95);padding:10px;margin:8px;max-width:260px;font-family:Inter,Arial,sans-serif;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.08)';

        container.innerHTML = `
            <div style="font-weight:600;margin-bottom:6px;">Chess Bot — Improved</div>
            <div id="depthText">Depth: <strong>${settings.lastDepth}</strong></div>
            <input type="range" id="depthSlider" min="1" max="30" value="${settings.lastDepth}" step="1" style="width:100%">
            <div style="margin-top:6px;"><input type="checkbox" id="autoRunCB"> <label for="autoRunCB">Auto Run</label></div>
            <div><input type="checkbox" id="autoMoveCB"> <label for="autoMoveCB">Auto Move</label></div>
            <div style="margin-top:6px;">Delay (s): <input id="delayInput" type="number" min="0" step="0.5" value="${settings.delay}" style="width:70px"></div>
            <div style="margin-top:8px;display:flex;gap:6px;"><button id="reloadBtn" style="flex:1;padding:6px;border-radius:6px">Reload Engine</button><button id="analyseBtn" style="flex:1;padding:6px;border-radius:6px">Analyse Now</button></div>
            <div style="margin-top:8px;font-size:12px;color:#666">Top 3 moves are highlighted briefly; PVs show on the board edge.</div>
        `;

        // append near board — conservative placement
        try{ board.parentElement.parentElement.appendChild(container); }catch(e){ document.body.appendChild(container); }

        // hydrate controls
        document.getElementById('autoRunCB').checked = !!settings.autoRun;
        document.getElementById('autoMoveCB').checked = !!settings.autoMovePiece;
        document.getElementById('depthSlider').oninput = e => { settings.lastDepth = parseInt(e.target.value); document.getElementById('depthText').innerHTML = `Depth: <strong>${settings.lastDepth}</strong>`; saveSettings(); };
        document.getElementById('autoRunCB').onchange = e => { settings.autoRun = e.target.checked; saveSettings(); };
        document.getElementById('autoMoveCB').onchange = e => { settings.autoMovePiece = e.target.checked; saveSettings(); };
        document.getElementById('delayInput').onchange = e => { settings.delay = parseFloat(e.target.value) || 0; saveSettings(); };
        document.getElementById('reloadBtn').onclick = () => { safeRestartEngine(); };
        document.getElementById('analyseBtn').onclick = () => { debouncedRun(settings.lastDepth); };

        return true;
    }

    // --------- Initialization & observers ---------
    async function waitUntil(conditionFn, interval=100){ return new Promise(resolve=>{ const t = setInterval(()=>{ try{ if(conditionFn()){ clearInterval(t); resolve(); } }catch(e){} }, interval); }); }

    (async function init(){
        await waitUntil(()=> findBoard());
        board = findBoard();
        // wait for the board.game object (Chess.com's internal game API) to be present
        await waitUntil(()=> (board = findBoard()) && board.game);
        // engine
        createStockfishWorker();
        initGUI();

        // observe for board/game resets (new game or navigation)
        const mo = new MutationObserver(()=>{ board = findBoard(); if(board && board.game){ /* no-op but keeps reference fresh */ } });
        mo.observe(document.body, {childList:true, subtree:true});

        // periodic auto loop
        setInterval(autoLoop, 150);

        // also monitor user moves (if board.game emits events, use them) — fallback to polling
        let lastMoveCount = null;
        setInterval(()=>{
            try{
                if(board && board.game){
                    const moves = board.game.getMoveHistory ? board.game.getMoveHistory().length : (board.game.history? board.game.history.length:0);
                    if(lastMoveCount === null) lastMoveCount = moves;
                    if(moves !== lastMoveCount){ // a move occurred — trigger analysis
                        lastMoveCount = moves;
                        if(settings.autoRun) debouncedRun(settings.lastDepth);
                    }
                }
            }catch(e){}
        }, 600);

        console.log('Improved Chess Bot ready');
    })();

})();