Automaticly solve your Duolingo chess lessons and play chess with Oscar for you.
// ==UserScript== // @name DuoChess Lite // @namespace duochess-lite // @version 1.0.0 // @icon https://i.ibb.co/gZpNbsPP/cosmic.jpg // @description Automaticly solve your Duolingo chess lessons and play chess with Oscar for you. // @match https://www.duolingo.com/* // @match https://*.duolingo.com/* // @run-at document-start // @grant none // @connect https://i.ibb.co/gZpNbsPP/cosmic.jpg // @connect https://stockfish.online // @connect https://esm.sh/[email protected] // @license MIT // @copyright DuoHacker // ==/UserScript== /* Just a small script for Duolingo Chess. My team and I probably won't update it much, so it might be a little broken lol. */ (() => { "use strict"; const BOT_CFG = { engine: "stockfish", jceLevel: 3, stockfishDepth: 12, clickDelay: 260, moveDelay: 700, thinkDelay: 350, boardInsetRatio: 64 / 648, flipped: false, autoPlay: true, postMoves: true, }; const SOL_CFG = { boardInsetRatio: 64 / 648, clickDelay: 180, moveDelay: 600, enemyDelay: 800, continueDelay: 600, autoContinue: true, flipped: false, }; const STORE_KEY = "duochess.v1.settings"; function loadSettings(){ try{ const saved=JSON.parse(localStorage.getItem(STORE_KEY)||"{}"); if(saved.bot){ // Drop legacy cosmic/minimax keys delete saved.bot.minimaxDepth; delete saved.bot.cosmicDepth; if(saved.bot.engine==="minimax"||saved.bot.engine==="cosmic"||saved.bot.engine==="auto") saved.bot.engine="stockfish"; Object.assign(BOT_CFG,saved.bot); } if(saved.solver) Object.assign(SOL_CFG,saved.solver); }catch(_){} } function saveSettings(){ try{ localStorage.setItem(STORE_KEY,JSON.stringify({bot:BOT_CFG,solver:SOL_CFG})); }catch(_){} } loadSettings(); // ══════════════════════════════════════════════════════════════════════════════ // UTILS // ══════════════════════════════════════════════════════════════════════════════ const sleep = ms => new Promise(r => setTimeout(r, ms)); const UCI_RE = /^[a-h][1-8][a-h][1-8][qrbn]?$/; const validUCI = s => typeof s === "string" && UCI_RE.test(s.trim()); const toUCI = s => String(s).trim().split(/\s+/).filter(validUCI); const esc = s => String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); async function boardStabilize(timeout = 3000, stableMs = 120, sampleInterval = 80) { const canvas = findCanvas(); if (!canvas) { await sleep(stableMs); return; } const ctx = canvas.getContext("2d"); if (!ctx) { await sleep(stableMs); return; } const w = Math.min(canvas.width, 64), h = Math.min(canvas.height, 64); const getHash = () => { try { const d = ctx.getImageData(0, 0, w, h).data; let s = 0; for (let i = 0; i < d.length; i += 16) s = (s * 31 + d[i] + d[i+1] + d[i+2]) | 0; return s; } catch(_) { return Math.random(); } }; const t0 = Date.now(); let prev = getHash(), stableSince = Date.now(); while (Date.now() - t0 < timeout) { await sleep(sampleInterval); const cur = getHash(); if (cur !== prev) { stableSince = Date.now(); prev = cur; } else if (Date.now() - stableSince >= stableMs) return; } } // ══════════════════════════════════════════════════════════════════════════════ // STATE // ══════════════════════════════════════════════════════════════════════════════ const BOT_S = { matchId: null, playerColor: null, currentFen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", moveHistory: [], status: "idle", authToken: null, jce: null, jceReady: false, stockfish: null, stockfishReady: false, engineName: "none", lastMove: null, }; const SOL_STATE = { raw: null, challenges: [], currentIdx: 0, solving: false, log: [], }; // ══════════════════════════════════════════════════════════════════════════════ // CANVAS & CLICK // ══════════════════════════════════════════════════════════════════════════════ function findCanvas() { return [...document.querySelectorAll("canvas")] .filter(c => { if (!c.isConnected) return false; const r = c.getBoundingClientRect(); return r.width > 200 && r.height > 200 && Math.abs(r.width/r.height - 1) < 0.2; }) .sort((a,b) => { const ra=a.getBoundingClientRect(),rb=b.getBoundingClientRect(); return (rb.width*rb.height)-(ra.width*ra.height); })[0] ?? null; } async function waitCanvas(timeout=10000) { const t0=Date.now(); while(Date.now()-t0<timeout){ const c=findCanvas(); if(c) return c; await sleep(50); } throw new Error("Canvas not found"); } function firePointer(el,type,x,y,buttons) { if(typeof PointerEvent==="function") el.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,composed:true,clientX:x,clientY:y,button:0,buttons,pointerId:1,pointerType:"mouse",isPrimary:true,view:window})); } function fireMouse(el,type,x,y,buttons) { el.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,composed:true,clientX:x,clientY:y,button:0,buttons,view:window})); } async function clickSquare(sq,insetRatio,flipped) { const canvas=await waitCanvas(); function coords(r) { const iw=r.width*insetRatio,ih=r.height*insetRatio; const bw=r.width-iw*2,bh=r.height-ih*2; const file=sq.charCodeAt(0)-97,rank=Number(sq[1]); const col=flipped?7-file:file,row=flipped?rank-1:8-rank; return {x:r.left+iw+(col+0.5)*bw/8,y:r.top+ih+(row+0.5)*bh/8}; } const d=coords(canvas.getBoundingClientRect()); firePointer(canvas,"pointerdown",d.x,d.y,1); fireMouse(canvas,"mousedown",d.x,d.y,1); await sleep(70); const u=coords(canvas.getBoundingClientRect()); firePointer(canvas,"pointerup",u.x,u.y,0); fireMouse(canvas,"mouseup",u.x,u.y,0); fireMouse(canvas,"click",u.x,u.y,0); } let _Chess = null; async function loadChessJS(){ try{ const mod=await import("https://esm.sh/[email protected]"); _Chess=mod.Chess??mod.default?.Chess??mod.default; addLog("sys","chess.js loaded"); reparseChallenges(); renderPanel(); }catch(e){addLog("sys","chess.js failed");} } async function loadJCE(){ try{ addLog("sys","Loading js-chess-engine..."); const mod=await import("https://esm.sh/[email protected]"); BOT_S.jce=mod.Game??mod.default?.Game; if(!BOT_S.jce) throw new Error("JCE Game class not found"); BOT_S.jceReady=true; addLog("sys","js-chess-engine ready"); renderPanel(); } catch(e){ addLog("sys","js-chess-engine failed: "+e.message); renderPanel(); } } // ══════════════════════════════════════════════════════════════════════════════ // ENGINE — Stockfish (stockfish.online REST API) // API: GET https://stockfish.online/api/s/v2.php?fen=<FEN>&depth=<N>&mode=bestmove // Returns JSON: { success: true, bestmove: "e2e4 ponder d7d5", ... } // Logo: https://stockfishchess.org/images/logo/[email protected] // ══════════════════════════════════════════════════════════════════════════════ async function loadStockfish(){ addLog("sys","Checking stockfish.online API..."); try{ const testFen = encodeURIComponent("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); const r = await _origFetch( `https://stockfish.online/api/s/v2.php?fen=${testFen}&depth=5&mode=bestmove`, {method:"GET"} ); if(!r.ok) throw new Error("HTTP "+r.status); const data = await r.json(); if(!data.success) throw new Error("API returned success:false"); BOT_S.stockfishReady = true; BOT_S.stockfish = { api: true }; addLog("sys","stockfish.online ready — bestmove: "+data.bestmove); renderPanel(); return true; } catch(e){ addLog("sys","stockfish.online unreachable: "+e.message); renderPanel(); return false; } } async function stockfishBestMove(fen, depth){ if(!BOT_S.stockfishReady) return null; try{ const encodedFen = encodeURIComponent(fen); const clampedDepth = Math.min(depth, 15); const r = await _origFetch( `https://stockfish.online/api/s/v2.php?fen=${encodedFen}&depth=${clampedDepth}&mode=bestmove`, {method:"GET"} ); if(!r.ok) throw new Error("HTTP "+r.status); const data = await r.json(); if(!data.success || !data.bestmove) throw new Error("No bestmove in response"); const mv = data.bestmove.replace(/^bestmove\s*/,"").split(/\s+/)[0]; return validUCI(mv) ? mv : null; } catch(e){ addLog("sys","SF API err: "+e.message); return null; } } // ══════════════════════════════════════════════════════════════════════════════ // ENGINE — DISPATCH // ══════════════════════════════════════════════════════════════════════════════ function activeEngineName(){ const e=BOT_CFG.engine; if(e==="stockfish"&&BOT_S.stockfishReady) return "Stockfish"; if(e==="jce"&&BOT_S.jceReady) return "js-chess-engine"; // fallback if chosen engine not ready if(BOT_S.stockfishReady) return "Stockfish"; if(BOT_S.jceReady) return "js-chess-engine"; return "none"; } async function getBestMove(fen){ const e=BOT_CFG.engine; // Stockfish (primary or fallback) if(BOT_S.stockfishReady&&(e==="stockfish"||e!=="jce")){ try{ const mv=await stockfishBestMove(fen,BOT_CFG.stockfishDepth); if(mv){ BOT_S.engineName="Stockfish"; return mv; } }catch(_){} } // JCE (primary or fallback) if(BOT_S.jceReady&&BOT_S.jce&&(e==="jce"||e!=="stockfish")){ try{ const game=new BOT_S.jce(fen),obj=game.aiMove(BOT_CFG.jceLevel); const[from,to]=Object.entries(obj)[0]; let uci=from.toLowerCase()+to.toLowerCase(); if((parseInt(from[1])===7&&parseInt(to[1])===8)||(parseInt(from[1])===2&&parseInt(to[1])===1)) uci+="q"; BOT_S.engineName="js-chess-engine"; return uci; }catch(_){} } // Last resort: random via chess.js if(_Chess){ try{ const chess=new _Chess(fen),moves=chess.moves({verbose:true}); if(moves.length){ const m=moves[Math.floor(Math.random()*moves.length)]; BOT_S.engineName="random"; return m.from+m.to+(m.promotion??""); } }catch(_){} } return null; } // ══════════════════════════════════════════════════════════════════════════════ // BOT LOGIC // ══════════════════════════════════════════════════════════════════════════════ const MATCHES_RE=/\/chess\/\d+\/\d+\/matches(?:\/([^/?#]+))?/; const MOVES_RE=/\/chess\/\d+\/\d+\/matches\/[^/?#]+\/moves/; const isMatchURL=url=>MATCHES_RE.test(url)&&!MOVES_RE.test(url); const isSessionURL=url=>typeof url==="string"&&/\/sessions(?:[/?#]|$)/i.test(url); const fenSide=fen=>fen?.split(" ")?.[1]??"w"; function isOurTurn(fen){if(!BOT_S.playerColor||!BOT_S.matchId)return false;const s=fenSide(fen);return(s==="w"&&BOT_S.playerColor==="white")||(s==="b"&&BOT_S.playerColor==="black");} function onMatchData(data){ if(!data) return; const match=data.match??(data.boardFen?data:null); if(!match) return; if(match.id&&!BOT_S.matchId){BOT_S.matchId=match.id;BOT_S.playerColor=match.playerColor??"white";addLog("bot",`Match ${match.id.slice(0,8)} — ${BOT_S.playerColor}`);} if(match.boardFen) BOT_S.currentFen=match.boardFen; if(Array.isArray(match.moveHistory)) BOT_S.moveHistory=[...match.moveHistory]; if(match.endCondition||match.status==="finished"){BOT_S.status="idle";renderPanel();return;} if(match.status==="active"&&isOurTurn(BOT_S.currentFen)){ if(BOT_S.status!=="thinking"&&BOT_S.status!=="playing"){ BOT_S.status="our_turn"; if(BOT_CFG.autoPlay) setTimeout(takeTurn,BOT_CFG.thinkDelay); } } else BOT_S.status="waiting"; renderPanel(); } async function waitCanvasChange(baseline, timeout=1200, interval=40){ const canvas=findCanvas(); if(!canvas||baseline===null) { await sleep(120); return; } const ctx=canvas.getContext("2d"); if(!ctx) { await sleep(120); return; } const w=Math.min(canvas.width,64), h=Math.min(canvas.height,64); const t0=Date.now(); while(Date.now()-t0<timeout){ await sleep(interval); try{ const d=ctx.getImageData(0,0,w,h).data; let s=0; for(let i=0;i<d.length;i+=16) s=(s*31+d[i]+d[i+1]+d[i+2])|0; if(s!==baseline) return; }catch(_){ return; } } } function canvasHash(){ const canvas=findCanvas(); if(!canvas) return null; try{ const ctx=canvas.getContext("2d"); if(!ctx) return null; const w=Math.min(canvas.width,64), h=Math.min(canvas.height,64); const d=ctx.getImageData(0,0,w,h).data; let s=0; for(let i=0;i<d.length;i+=16) s=(s*31+d[i]+d[i+1]+d[i+2])|0; return s; }catch(_){ return null; } } async function takeTurn(){ if(BOT_S.status==="thinking"||BOT_S.status==="playing") return; BOT_S.status="thinking"; renderPanel(); const move=await getBestMove(BOT_S.currentFen); if(!move){BOT_S.status="idle";renderPanel();return;} BOT_S.status="playing"; BOT_S.lastMove=move; renderPanel(); try{ const flip=BOT_CFG.flipped||BOT_S.playerColor==="black"; const hashBefore=canvasHash(); await clickSquare(move.slice(0,2),BOT_CFG.boardInsetRatio,flip); await waitCanvasChange(hashBefore, 1000, 30); await sleep(Math.max(BOT_CFG.clickDelay, 120)); await clickSquare(move.slice(2,4),BOT_CFG.boardInsetRatio,flip); if(move[4]){ await sleep(350); const name={q:"queen",r:"rook",b:"bishop",n:"knight"}[move[4]]??"queen"; for(const sel of[`[data-piece="${name}"]`,`[aria-label*="${name}" i]`]){const el=document.querySelector(sel);if(el){el.click();break;}} } await sleep(BOT_CFG.moveDelay); if(BOT_CFG.postMoves&&BOT_S.matchId) await postMove(move); addLog("bot",`${move} [${BOT_S.engineName}]`); BOT_S.status="waiting"; } catch(e){addLog("bot","err: "+e.message);BOT_S.status="idle";} renderPanel(); } async function postMove(uci){ const uid=location.pathname.match(/\/(\d+)\//)?.[1]??"0"; const hdrs={"Content-Type":"application/json"}; if(BOT_S.authToken) hdrs["Authorization"]=BOT_S.authToken; try{ const res=await _origFetch(`/chess/1/${uid}/matches/${BOT_S.matchId}/moves`,{method:"POST",headers:hdrs,body:JSON.stringify({move:uci})}); const data=await res.json(),m=data.match??data; if(m?.boardFen) BOT_S.currentFen=m.boardFen; if(m?.boardFen&&isOurTurn(m.boardFen)&&BOT_CFG.autoPlay) setTimeout(takeTurn,BOT_CFG.thinkDelay+BOT_CFG.moveDelay); } catch(_){} } // ══════════════════════════════════════════════════════════════════════════════ // SOLVER // ══════════════════════════════════════════════════════════════════════════════ function _sanitizeDuoFen(fen){ const parts=fen.split(" "); const rows=parts[0].split("/"); rows[0]=rows[0].replace(/[pP]/g,ch=>ch==="p"?"q":"Q"); rows[7]=rows[7].replace(/[pP]/g,ch=>ch==="p"?"q":"Q"); parts[0]=rows.join("/"); const board=parts[0]; const hasWK=/K/.test(board), hasBK=/k/.test(board); if(!hasWK||!hasBK){ const r2=parts[0].split("/"); const expand=row=>{const c=[];for(const ch of row){if(/\d/.test(ch))for(let i=0;i<+ch;i++)c.push(".");else c.push(ch);}return c;}; const compress=c=>{let s="",e=0;for(const x of c){if(x==="."){e++;}else{if(e)s+=e;s+=x;e=0;}}if(e)s+=e;return s;}; const grid=r2.map(expand); const place=(g,p,rs)=>{for(const r of rs)for(let f=7;f>=0;f--)if(g[r][f]==="."){g[r][f]=p;return;}}; if(!hasWK) place(grid,"K",[7,6,5,4]); if(!hasBK) place(grid,"k",[0,1,2,3]); parts[0]=grid.map(compress).join("/"); if(parts.length>=3) parts[2]="-"; } return parts.join(" "); } function _forceWhite(fen){ const p=fen.split(" "); p[1]="w"; p[2]="-"; p[3]="-"; return p.join(" "); } function starCaptureAdapter(fen, seedMoves, maxMoves){ if(!_Chess) return null; try{ let workFen=_sanitizeDuoFen(fen); const steps=[]; const limit=maxMoves??16; const pieceVal={p:1,n:3,b:3,r:5,q:9,k:0}; for(const uci of seedMoves){ if(!validUCI(uci)) continue; workFen=_forceWhite(workFen); const c=new _Chess(workFen); const res=c.move({from:uci.slice(0,2),to:uci.slice(2,4),promotion:uci[4]??undefined}); if(!res) break; steps.push({kind:"player",move:uci}); workFen=_forceWhite(c.fen()); } let iters=0; while(steps.length<limit&&iters++<32){ workFen=_forceWhite(workFen); const c=new _Chess(workFen); const moves=c.moves({verbose:true}); const caps=moves.filter(m=>m.captured&&m.captured!=="k"); if(!caps.length) break; caps.sort((a,b)=>(pieceVal[b.captured??""]??0)-(pieceVal[a.captured??""]??0)); const best=caps[0]; c.move(best); workFen=c.fen(); steps.push({kind:"player",move:best.from+best.to+(best.promotion??"")}); } return steps.length>0 ? steps : null; }catch(_){ return null; } } function countBlackPieces(fen){ const board=fen.split(" ")[0]; return(board.match(/p/g)??[]).length; } function buildSequence(info, fen){ const correct=(info.correctMoves??[]).flatMap(toUCI); const enemy=(info.enemyMoves??[]).flatMap(toUCI); const validPth=(info.validPaths??[]).map(v=>toUCI(String(v))); const hiMoves=(info.highlight??[]).flatMap(v=>String(v).match(/\b[a-h][1-8][a-h][1-8][qrbn]?\b/g)??[]); const maxMoves=info.maxMoves??undefined; const hasEnemy=enemy.length>0; const starCount=fen?countBlackPieces(fen):0; if(correct.length>0){ const steps=correct.map(m=>({kind:"player",move:m})); if(hasEnemy){ const mixed=[]; correct.forEach((m,i)=>{mixed.push({kind:"player",move:m});if(i<enemy.length)mixed.push({kind:"enemy",move:enemy[i]});}); return{source:"correctMoves",steps:mixed,allPaths:validPth}; } return{source:"correctMoves",steps,allPaths:validPth}; } if(validPth.length>0&&validPth[0].length>0){ const seed=validPth[0]; return{source:"validPaths",steps:seed.map(m=>({kind:"player",move:m})),allPaths:validPth}; } if(hiMoves.length>0){ if(_Chess&&fen){ const adapted=starCaptureAdapter(fen,hiMoves,maxMoves); if(adapted&&adapted.length>0) return{source:"adapter(highlight)",steps:adapted,allPaths:[]}; } return{source:"highlight",steps:hiMoves.map(m=>({kind:"player",move:m})),allPaths:[]}; } if(_Chess&&fen&&starCount>0){ const adapted=starCaptureAdapter(fen,[],maxMoves); if(adapted&&adapted.length>0) return{source:"adapter(fen)",steps:adapted,allPaths:[]}; } const evalMap=info.moveEvaluationsForPositions??{},evalSteps=[]; for(const k of Object.keys(evalMap)){ const best=evalMap[k].filter(e=>e.moveCorrectness==="correct").sort((a,b)=>b.wdl-a.wdl)[0]; if(best&&validUCI(best.move)){evalSteps.push({kind:"player",move:best.move});if(best.enemyResponse&&validUCI(best.enemyResponse))evalSteps.push({kind:"enemy",move:best.enemyResponse});} } if(evalSteps.length>0) return{source:"evalFallback",steps:evalSteps,allPaths:[]}; return{source:"none",steps:[],allPaths:[]}; } function parseChallenge(raw,idx){ const p=buildSequence(raw?.chessPuzzleInfo??{}, raw?.fen??""); return{idx,id:raw.id??`ch_${idx}`,fen:raw.fen??"",source:p.source,steps:p.steps,allPaths:p.allPaths,raw}; } function reparseChallenges(){ if(!SOL_STATE.raw||!SOL_STATE.challenges.length) return; const prevIdx=SOL_STATE.currentIdx; SOL_STATE.challenges=[...(SOL_STATE.raw.challenges??[]),...(SOL_STATE.raw.adaptiveChallenges??[])].map(parseChallenge); SOL_STATE.currentIdx=prevIdx; addLog("solver","Re-parsed with StarAdapter: "+SOL_STATE.challenges.length+" challenges"); renderPanel(); } function processSession(session){ if(!Array.isArray(session?.challenges)) return; SOL_STATE.raw=session; SOL_STATE.currentIdx=0; SOL_STATE.challenges=[...(session.challenges??[]),...(session.adaptiveChallenges??[])].map(parseChallenge); addLog("solver",`Session loaded: ${SOL_STATE.challenges.length} challenges`); renderPanel(); } async function clickContinue(){ const t0=Date.now(); while(Date.now()-t0<6000){ const b=document.querySelector('button[data-test="player-next"]:not([aria-disabled="true"])') ??[...document.querySelectorAll("button")].find(x=>{const t=x.textContent.trim().toLowerCase();return(t==="tiep tuc"||t==="continue"||t==="tiếp tục")&&x.getAttribute("aria-disabled")!=="true"&&x.isConnected;}); if(b){b.click();return true;} await sleep(100); } return false; } // Fast canvas-change detector: returns as soon as hash differs from baseline, or timeout async function _waitBoardChange(baseline, timeout=1500, interval=20){ const canvas=findCanvas(); if(!canvas||baseline===null){await sleep(60);return;} const ctx=canvas.getContext("2d"); if(!ctx){await sleep(60);return;} const w=Math.min(canvas.width,64),h=Math.min(canvas.height,64); const t0=Date.now(); while(Date.now()-t0<timeout){ await sleep(interval); try{ const d=ctx.getImageData(0,0,w,h).data; let s=0;for(let i=0;i<d.length;i+=16)s=(s*31+d[i]+d[i+1]+d[i+2])|0; if(s!==baseline)return; }catch(_){return;} } } // Fast canvas hash snapshot function _canvasSnap(){ const canvas=findCanvas(); if(!canvas)return null; try{ const ctx=canvas.getContext("2d"); if(!ctx)return null; const w=Math.min(canvas.width,64),h=Math.min(canvas.height,64); const d=ctx.getImageData(0,0,w,h).data; let s=0;for(let i=0;i<d.length;i+=16)s=(s*31+d[i]+d[i+1]+d[i+2])|0; return s; }catch(_){return null;} } async function solveChallenge(ch){ if(!ch.steps.length){addLog("solver",`#${ch.idx} no steps (${ch.source})`);return;} addLog("solver",`Solving #${ch.idx} [${ch.source}]`); for(const step of ch.steps){ renderPanel(); if(step.kind==="player"){ if(!validUCI(step.move)) throw new Error("Invalid UCI: "+step.move); addLog("solver",`Move: ${step.move}`); const h0=_canvasSnap(); await clickSquare(step.move.slice(0,2),SOL_CFG.boardInsetRatio,SOL_CFG.flipped); await sleep(SOL_CFG.clickDelay); // gap between from→to click await clickSquare(step.move.slice(2,4),SOL_CFG.boardInsetRatio,SOL_CFG.flipped); // Wait for board to actually change (our piece moved), then a short settle await _waitBoardChange(h0, 1500, 20); await sleep(SOL_CFG.moveDelay); // let animation finish } else { // Enemy move: just poll until board changes — no fixed sleep addLog("solver",`Waiting enemy: ${step.move}`); const h1=_canvasSnap(); await _waitBoardChange(h1, SOL_CFG.enemyDelay + 800, 20); await sleep(80); // tiny settle after enemy animation } } addLog("solver",`#${ch.idx} complete`); if(SOL_CFG.autoContinue){await sleep(SOL_CFG.continueDelay);await clickContinue();} } async function solve(idx=SOL_STATE.currentIdx){ if(SOL_STATE.solving) throw new Error("Already solving"); const ch=SOL_STATE.challenges[idx];if(!ch) throw new Error("No challenge at "+idx); SOL_STATE.solving=true;try{await solveChallenge(ch);}finally{SOL_STATE.solving=false;renderPanel();} } async function solveNext(){ if(!SOL_STATE.challenges.length) throw new Error("No session loaded"); if(SOL_STATE.currentIdx>=SOL_STATE.challenges.length){addLog("solver","All done");return;} await solve(SOL_STATE.currentIdx);SOL_STATE.currentIdx++;renderPanel(); } async function solveAll(){ if(SOL_STATE.solving) throw new Error("Already solving"); while(SOL_STATE.currentIdx<SOL_STATE.challenges.length){await solveNext();await sleep(200);} addLog("solver","All challenges complete");renderPanel(); } // ══════════════════════════════════════════════════════════════════════════════ // NETWORK HOOKS // ══════════════════════════════════════════════════════════════════════════════ let _lastSessionUrl = null; const _origFetch=window.fetch; window.fetch=async function(...args){ const res=await _origFetch.apply(this,args); const url=typeof args[0]==="string"?args[0]:(args[0]?.url??res.url??""); if(args[1]?.headers){const h=args[1].headers;const tok=typeof h.get==="function"?h.get("authorization"):h["authorization"];if(tok)BOT_S.authToken=tok;} if(isMatchURL(url)) res.clone().json().then(onMatchData).catch(()=>{}); if(isSessionURL(url)) { _lastSessionUrl = url; res.clone().json().then(processSession).catch(()=>{}); } return res; }; const _xOpen=XMLHttpRequest.prototype.open,_xSend=XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open=function(m,url,...r){this.__dcUrl=String(url??"");return _xOpen.call(this,m,url,...r);}; XMLHttpRequest.prototype.send=function(...args){ const url=this.__dcUrl; if(isMatchURL(url)||isSessionURL(url)){ this.addEventListener("load",()=>{ try{const d=this.responseType==="json"?this.response:JSON.parse(this.responseText);if(isMatchURL(url))onMatchData(d);if(isSessionURL(url)){_lastSessionUrl=url;processSession(d);}}catch(_){} }); } return _xSend.apply(this,args); }; // ══════════════════════════════════════════════════════════════════════════════ // LOG // ══════════════════════════════════════════════════════════════════════════════ function addLog(source,msg){ SOL_STATE.log.push({source,msg,time:new Date().toLocaleTimeString("vi-VN",{hour:"2-digit",minute:"2-digit",second:"2-digit"})}); if(SOL_STATE.log.length>120) SOL_STATE.log.shift(); renderPanel(); } // ══════════════════════════════════════════════════════════════════════════════ // ── Chủ động fetch /sessions từ URL hiện tại ── async function _fetchSession() { let sessionUrl = null; // 1. Dùng URL đã cache từ hook (reliable nhất) if (_lastSessionUrl) sessionUrl = _lastSessionUrl; // 2. Scan performance entries (chỉ có sau khi page load xong) if (!sessionUrl) { try { const SESSION_RE = /\/sessions(?:[/?#&]|$)/i; const hit = performance.getEntriesByType("resource") .find(e => SESSION_RE.test(e.name)); if (hit) sessionUrl = hit.name; } catch (_) {} } if (!sessionUrl) { const date = new Date().toISOString().slice(0, 10); const candidates = [ `https://www.duolingo.com/${date}/sessions`, `/api/1/sessions`, `/${date}/sessions`, ]; for (const url of candidates) { try { const hdrs = {}; if (BOT_S.authToken) hdrs["Authorization"] = BOT_S.authToken; const r = await _origFetch(url, { method: "GET", headers: hdrs }); if (r.ok) { sessionUrl = url; break; } } catch (_) {} } } if (!sessionUrl) { addLog("solver", "[fetch] /sessions URL not found — navigate to a chess lesson first"); return false; } try { const hdrs = {}; if (BOT_S.authToken) hdrs["Authorization"] = BOT_S.authToken; const r = await _origFetch(sessionUrl, { method: "GET", headers: hdrs }); if (!r.ok) { addLog("solver", "[fetch] /sessions HTTP " + r.status); return false; } const data = await r.json(); processSession(data); return true; } catch (e) { addLog("solver", "[fetch] /sessions err: " + e.message); return false; } } // SVG ICONS // ══════════════════════════════════════════════════════════════════════════════ const ICONS = { play: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M4 3l10 5-10 5V3z" fill="currentColor"/></svg>`, pause: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="4" height="12" rx="1" fill="currentColor"/><rect x="9" y="2" width="4" height="12" rx="1" fill="currentColor"/></svg>`, next: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 3l7 5-7 5V3z" fill="currentColor"/><rect x="11" y="3" width="2" height="10" rx="1" fill="currentColor"/></svg>`, skipall: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 3l5 5-5 5V3z" fill="currentColor"/><path d="M8 3l5 5-5 5V3z" fill="currentColor"/></svg>`, flip: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 6h8M7 3l3 3-3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 10H6m3-3l-3 3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`, close: `<svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`, minus: `<svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`, chess: ` <svg width="18" height="18" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M326.008826 236.527964h364.960302c8.422161 0 16.844322 9.825854 18.949862 22.459096l89.836382 701.846734a19.651709 19.651709 0 0 1-21.055402 22.459095H239.681678a19.651709 19.651709 0 0 1-20.353556-22.459095l90.538229-701.846734c1.403693-12.633241 9.825854-22.459095 16.142475-22.459096z" fill="currentColor"/> <path d="M191.254253 939.076545l646.400842 0 0 84.221608-646.400842 0 0-84.221608Z" fill="currentColor"/> <path d="M310.568198 433.04505h400.052638q17.546168 0 18.949862-5.614774L838.356942 249.863052s-18.248015-14.036935-32.28495-14.036935H213.011502c-14.036935 0-23.160942 10.527701-21.055402 14.036935L286.705409 421.113655a21.757249 21.757249 0 0 0 11.931394 9.825855z" fill="currentColor"/> <path d="M726.763311 0.005615h65.973593a43.514498 43.514498 0 0 1 44.216344 44.216344V259.688906a43.514498 43.514498 0 0 1-43.514497 44.216345h-561.477387A44.216344 44.216344 0 0 1 185.639479 259.688906V44.221959A44.216344 44.216344 0 0 1 229.855823 0.005615h70.184674v118.612098A21.757249 21.757249 0 0 0 326.008826 140.374962h56.147739a21.757249 21.757249 0 0 0 21.757248-21.757249V0.005615h218.976182v118.612098a21.757249 21.757249 0 0 0 21.757248 21.757249h59.656973a21.757249 21.757249 0 0 0 21.757248-21.757249z" fill="currentColor"/> </svg>`, bolt: ` <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M17.502 12.033l-4.241-2.458 2.138-5.131c.066-.134.103-.285.103-.444 0-.552-.445-1-.997-1-.249.004-.457.083-.622.214l-.07.06-7.5 7.1c-.229.217-.342.529-.306.842.036.313.219.591.491.75l4.242 2.46-2.163 5.19c-.183.436-.034.94.354 1.208.173.118.372.176.569.176.248 0 .496-.093.688-.274l7.5-7.102c.229-.217.342-.529.306-.842-.037-.313-.22-.591-.492-.749z" fill="currentColor"/> </svg>`, log: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="2" y="3" width="12" height="1.5" rx="0.75" fill="currentColor"/><rect x="2" y="7" width="9" height="1.5" rx="0.75" fill="currentColor"/><rect x="2" y="11" width="11" height="1.5" rx="0.75" fill="currentColor"/></svg>`, settings: ` <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M12 4a1 1 0 0 0-1 1c0 1.692-2.046 2.54-3.243 1.343a1 1 0 1 0-1.414 1.414C7.54 8.954 6.693 11 5 11a1 1 0 1 0 0 2c1.692 0 2.54 2.046 1.343 3.243a1 1 0 0 0 1.414 1.414C8.954 16.46 11 17.307 11 19a1 1 0 1 0 2 0c0-1.692 2.046-2.54 3.243-1.343a1 1 0 1 0 1.414-1.414C16.46 15.046 17.307 13 19 13a1 1 0 1 0 0-2c-1.692 0-2.54-2.046-1.343-3.243a1 1 0 0 0-1.414-1.414C15.046 7.54 13 6.693 13 5a1 1 0 0 0-1-1zm-2.992.777a3 3 0 0 1 5.984 0 3 3 0 0 1 4.23 4.231 3 3 0 0 1 .001 5.984 3 3 0 0 1-4.231 4.23 3 3 0 0 1-5.984 0 3 3 0 0 1-4.231-4.23 3 3 0 0 1 0-5.984 3 3 0 0 1 4.231-4.231z" fill="currentColor"/> <path d="M12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-2.828-.828a4 4 0 1 1 5.656 5.656 4 4 0 0 1-5.656-5.656z" fill="currentColor"/> </svg>`, check: `<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M2 7l4 4 6-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`, reload: ` <svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.9372 4.21148C14.3936 4.52244 14.5115 5.14453 14.2005 5.60095C13.8896 6.05738 13.2675 6.1753 12.8111 5.86434C11.9885 5.30394 11.0183 5 10 5C7.23858 5 5 7.23858 5 10C5 12.7614 7.23858 15 10 15C12.7614 15 15 12.7614 15 10C15 9.44772 15.4477 9 16 9C16.5523 9 17 9.44772 17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C11.4232 3 12.7852 3.42666 13.9372 4.21148Z" fill="currentColor"/> <path d="M13.5385 12.5062C13.0732 12.8038 12.4548 12.6679 12.1572 12.2026C11.8596 11.7373 11.9955 11.1189 12.4608 10.8214L15.9426 8.59426C16.4079 8.29667 17.0263 8.43258 17.3239 8.89784C17.6215 9.36309 17.4855 9.98149 17.0203 10.2791L13.5385 12.5062Z" fill="currentColor"/> <path d="M18.9034 12.4104C19.1284 12.9147 18.9019 13.506 18.3976 13.731C17.8932 13.956 17.3019 13.7295 17.0769 13.2252L15.5688 9.84436C15.3438 9.33999 15.5702 8.74871 16.0746 8.52371C16.579 8.29871 17.1703 8.52519 17.3953 9.02957L18.9034 12.4104Z" fill="currentColor"/> </svg>`, cpu: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="3" y="3" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="5.5" y="5.5" width="5" height="5" rx="0.5" fill="currentColor"/><path d="M5 1v2M8 1v2M11 1v2M5 13v2M8 13v2M11 13v2M1 5h2M1 8h2M1 11h2M13 5h2M13 8h2M13 11h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>`, dot: `<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>`, }; // ══════════════════════════════════════════════════════════════════════════════ // STYLES (Duolingo × Apple) // ══════════════════════════════════════════════════════════════════════════════ const STYLE = ` @font-face { font-family: "SF Pro Rounded"; src: url("https://font.duohacker.io.vn/SF-Pro-Rounded-Regular.otf") format("opentype"); font-weight: 400; font-display: swap; } @font-face { font-family: "SF Pro Rounded"; src: url("https://font.duohacker.io.vn/SF-Pro-Rounded-Semibold.otf") format("opentype"); font-weight: 600; font-display: swap; } @font-face { font-family: "SF Pro Rounded"; src: url("https://font.duohacker.io.vn/SF-Pro-Rounded-Bold.otf") format("opentype"); font-weight: 700; font-display: swap; } @font-face { font-family: "SF Pro Rounded"; src: url("https://font.duohacker.io.vn/SF-Pro-Rounded-Heavy.otf") format("opentype"); font-weight: 800; font-display: swap; } @font-face { font-family: "SF Pro Rounded"; src: url("https://font.duohacker.io.vn/SF-Pro-Rounded-Black.otf") format("opentype"); font-weight: 900; font-display: swap; } #dc-panel,#dc-panel*{box-sizing:border-box;margin:0;padding:0;} /* ── DESIGN TOKENS (DuoRain-style CSS vars) ── */ #dc-panel{ --glass: blur(26px) saturate(140%); --shadow: 0 24px 64px rgba(0,0,0,0.55), 0 0 0 0.5px rgba(255,255,255,0.03), inset 0 1px 0 rgba(255,255,255,0.06); --green: 88,204,2; --green-hex: #58cc02; --green-light: #78e000; /* dark defaults */ --bg: rgba(18,20,28,0.88); --sidebar: rgba(12,14,20,0.82); --surface: rgba(255,255,255,0.035); --hover: rgba(255,255,255,0.07); --input-bg: rgba(255,255,255,0.055); --swan: rgba(255,255,255,0.08); --swan2: rgba(255,255,255,0.05); --eel: #e2e4f0; --wolf: rgba(255,255,255,0.35); --muted: rgba(255,255,255,0.18); --dropdown: rgba(15,17,24,0.97); } #dc-panel.dc-light{ --bg: rgba(230,234,248,0.82); --sidebar: rgba(210,216,240,0.88); --surface: rgba(0,0,0,0.04); --hover: rgba(0,0,0,0.06); --input-bg: rgba(0,0,0,0.05); --swan: rgba(0,0,0,0.09); --swan2: rgba(0,0,0,0.05); --eel: #1a1c28; --wolf: rgba(0,0,0,0.45); --muted: rgba(0,0,0,0.22); --dropdown: rgba(230,234,248,0.98); } /* ── PANEL ── */ #dc-panel{ position:fixed;bottom:24px;right:24px; width:500px; max-width:calc(100vw - 48px); max-height:calc(100vh - 48px); display:flex;flex-direction:column; border-radius:20px; background:var(--bg); backdrop-filter:var(--glass); -webkit-backdrop-filter:var(--glass); border:1px solid var(--swan); box-shadow:var(--shadow); font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif; font-size:13px;color:var(--eel); user-select:none;z-index:2147483647; overflow:hidden; transition:opacity .28s cubic-bezier(.2,0,.2,1), transform .28s cubic-bezier(.2,0,.2,1), filter .28s; } #dc-panel * { font-family: 'SF Pro Rounded','Nunito', system-ui, sans-serif; } #dc-panel.dc-hidden{opacity:0;pointer-events:none;transform:translateY(10px) scale(0.97);filter:blur(3px);} /* ── TITLE BAR ── */ #dc-bar{ display:flex;align-items:center;padding:0 14px; height:48px;flex-shrink:0; border-bottom:1px solid rgba(255,255,255,0.06); cursor:grab;gap:0; } #dc-bar:active{cursor:grabbing;} #dc-wordmark{ flex:1;display:flex;align-items:center;gap:9px; font-size:13px;font-weight:900;letter-spacing:0.04em; color:#e2e4f0; } #dc-wordmark .dc-avatar{ width:26px;height:26px;border-radius:8px; border:1.5px solid rgba(88,204,2,0.4); object-fit:cover;flex-shrink:0; } #dc-wordmark .dc-title-text{ background:linear-gradient(90deg,#78e000 0%,#b8ff40 60%); -webkit-background-clip:text;-webkit-text-fill-color:transparent; background-clip:text;letter-spacing:0.06em; } #dc-wordmark .dc-ver-badge{ font-size:9px;font-weight:800;letter-spacing:0.1em; color:rgba(120,224,0,0.6);background:rgba(88,204,2,0.08); border:1px solid rgba(88,204,2,0.18); padding:2px 6px;border-radius:20px; -webkit-text-fill-color:initial; } .dc-winbtn{ width:26px;height:26px;border-radius:7px; border:1px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.3);cursor:pointer; display:flex;align-items:center;justify-content:center; transition:all .12s;margin-left:5px; } .dc-winbtn:hover{background:rgba(255,255,255,0.1);color:#fff;border-color:rgba(255,255,255,0.18);} .dc-winbtn:active{transform:scale(0.88);} /* ── TAB BAR ── */ #dc-tabs{ display:flex;flex-shrink:0; border-bottom:1px solid rgba(255,255,255,0.05); background:rgba(0,0,0,0.12); padding:6px 10px 0;gap:2px; } .dc-tab{ display:flex;align-items:center;gap:5px; padding:0 10px;height:34px; border:none;background:transparent; color:rgba(255,255,255,0.3);font-family:'SF Pro Rounded','Nunito',sans-serif; font-size:11.5px;font-weight:800; cursor:pointer;letter-spacing:0.01em; border-radius:9px 9px 0 0; border-bottom:2px solid transparent;margin-bottom:-1px; transition:color .14s,background .14s,border-color .14s; white-space:nowrap; } .dc-tab svg{flex-shrink:0;} .dc-tab:hover{color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.04);} .dc-tab.on{color:#78e000;border-bottom-color:#78e000;background:rgba(88,204,2,0.05);} /* ── PANE ── */ #dc-pane{ overflow-y:auto;overflow-x:hidden; padding:12px;display:flex;flex-direction:column;gap:8px; flex:1 1 auto;min-height:120px;max-height:320px; } #dc-pane::-webkit-scrollbar{width:4px;} #dc-pane::-webkit-scrollbar-track{background:rgba(255,255,255,0.03);border-radius:4px;margin:6px 0;} #dc-pane::-webkit-scrollbar-thumb{background:linear-gradient(180deg,rgba(88,204,2,0.5) 0%,rgba(88,204,2,0.18) 100%);border-radius:4px;} #dc-pane::-webkit-scrollbar-thumb:hover{background:linear-gradient(180deg,rgba(120,224,0,0.8) 0%,rgba(88,204,2,0.45) 100%);} #dc-pane::-webkit-scrollbar-thumb:active{background:rgba(120,224,0,0.9);} #dc-pane{scrollbar-width:thin;scrollbar-color:rgba(88,204,2,0.35) rgba(255,255,255,0.03);} .dc-section-label{ font-size:10px;font-weight:800;letter-spacing:0.12em; text-transform:uppercase;color:rgba(255,255,255,0.2);padding:0 2px; } /* ── CARD ── */ .dc-card{ background:rgba(255,255,255,0.035); border:1px solid rgba(255,255,255,0.07); border-radius:14px; } /* Children that need clipped corners (grids, progress, lists) get their own clip */ .dc-card>.dc-kv-grid,.dc-card>.dc-prog-wrap,.dc-card>.dc-ch-list{overflow:hidden;border-radius:13px;} .dc-card>.dc-kv-grid{border-radius:13px;} .dc-card>.dc-ch-list .dc-ch-item:first-child{border-radius:13px 13px 0 0;} .dc-card>.dc-ch-list .dc-ch-item:last-child{border-radius:0 0 13px 13px;} /* ── BUTTONS ── */ .dc-btn-row{display:flex;gap:7px;padding:10px;flex-wrap:wrap;} .btn{ display:inline-flex;align-items:center;justify-content:center;gap:6px; padding:0 14px;height:38px;border-radius:11px;border:1.5px solid; font-family:'SF Pro Rounded','Nunito',sans-serif;font-size:13px;font-weight:800;cursor:pointer; letter-spacing:0.01em;white-space:nowrap;flex-shrink:0; transition:all .15s cubic-bezier(.34,1.56,.64,1); } .btn:active{transform:scale(0.94)!important;} #dc-panel .btn.primary{ background:#58cc02 !important; border-color:#58cc02 !important;color:#fff !important; box-shadow:0 2px 8px rgba(88,204,2,0.25) !important; } #dc-panel .btn.primary:hover{background:#63db02 !important;transform:translateY(-1px);box-shadow:0 4px 14px rgba(88,204,2,0.35) !important;} #dc-panel .btn.green{background:rgba(88,204,2,0.09) !important;border-color:rgba(88,204,2,0.28) !important;color:#78e000 !important;} #dc-panel .btn.green:hover{background:rgba(88,204,2,0.16) !important;border-color:rgba(88,204,2,0.45) !important;transform:translateY(-1px);} #dc-panel .btn.ghost{background:rgba(255,255,255,0.04) !important;border-color:rgba(255,255,255,0.09) !important;color:rgba(255,255,255,0.4) !important;} #dc-panel .btn.ghost:hover{background:rgba(255,255,255,0.09) !important;border-color:rgba(255,255,255,0.18) !important;color:#e2e4f0 !important;} #dc-panel .btn.ghost.on{border-color:rgba(120,224,0,0.35) !important;color:#78e000 !important;background:rgba(88,204,2,0.07) !important;} #dc-panel .btn.fill{flex:1;} /* ── STATUS BAR ── */ .dc-status-bar{ display:flex;align-items:center;gap:8px; padding:7px 14px;border-top:1px solid rgba(255,255,255,0.05); background:rgba(0,0,0,0.15); font-size:11px; border-radius:0 0 20px 20px; } .dc-status-dot{ width:6px;height:6px;border-radius:50%;flex-shrink:0;transition:background .3s; } .dc-status-dot.idle {background:rgba(255,255,255,0.12);} .dc-status-dot.our_turn{background:#ffd900;animation:dc-pulse 1s infinite;box-shadow:0 0 6px #ffd900;} .dc-status-dot.thinking{background:#a855f7;animation:dc-pulse .7s infinite;box-shadow:0 0 6px #a855f7;} .dc-status-dot.playing {background:#78e000;box-shadow:0 0 6px #78e000;} .dc-status-dot.waiting {background:rgba(255,255,255,0.12);} @keyframes dc-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.8)}} .dc-status-txt{flex:1;font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;font-size:10px;color:rgba(255,255,255,0.25);} .dc-status-move{font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;font-weight:600;color:#78e000;font-size:11px;} /* ── KV GRID ── */ .dc-kv-grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:rgba(255,255,255,0.05);} .dc-kv-cell{background:rgba(22,23,30,0.95);padding:10px 12px;display:flex;flex-direction:column;gap:3px;} .dc-kv-label{font-size:9px;font-weight:800;letter-spacing:0.1em;text-transform:uppercase;color:rgba(255,255,255,0.22);} .dc-kv-value{font-size:13px;font-weight:700;color:#d8dae8;font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;} .dc-kv-value.accent{color:#78e000;} .dc-kv-value.yellow{color:#ffd900;} .dc-kv-value.dim {color:rgba(255,255,255,0.18);} /* ── FEN ── */ .dc-fen{ font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;font-size:9px; color:rgba(255,255,255,0.15);word-break:break-all;line-height:1.8; padding:8px 12px;background:rgba(0,0,0,0.12);border-top:1px solid rgba(255,255,255,0.05); } /* ── CHALLENGES ── */ .dc-ch-list{display:flex;flex-direction:column;} .dc-ch-item{ display:flex;align-items:flex-start;gap:9px; padding:9px 12px;border-bottom:1px solid rgba(255,255,255,0.04); } .dc-ch-item:last-child{border-bottom:none;} .dc-ch-item.current{background:rgba(88,204,2,0.04);} .dc-ch-item.done{opacity:.3;} .dc-ch-num{ width:24px;height:24px;border-radius:7px;flex-shrink:0; display:flex;align-items:center;justify-content:center; font-size:10px;font-weight:800;font-family:'SF Pro Rounded','Nunito',sans-serif;margin-top:1px; } .dc-ch-num.current{background:rgba(88,204,2,0.14);color:#78e000;} .dc-ch-num.done {background:rgba(88,204,2,0.05);color:rgba(88,204,2,0.35);} .dc-ch-num.pending{background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.18);} .dc-ch-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px;} .dc-ch-src{ font-size:9px;font-weight:800;letter-spacing:0.08em;text-transform:uppercase; padding:2px 5px;border-radius:5px;display:inline-block; } .dc-ch-moves{font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;font-size:10px;color:rgba(255,255,255,0.25);line-height:1.6;} .dc-ch-moves .mv-player{color:#78e000;} .dc-ch-moves .mv-enemy {color:#ff6060;} /* ── SETTINGS ── */ .dc-setting-row{ display:flex;align-items:center;justify-content:space-between;gap:10px; padding:10px 13px;border-bottom:1px solid rgba(255,255,255,0.04); } .dc-setting-row:last-child{border-bottom:none;} .dc-setting-label{font-size:12px;color:rgba(255,255,255,0.65);font-weight:700;} .dc-setting-desc{font-size:10px;color:rgba(255,255,255,0.18);margin-top:1px;} /* ── TOGGLE SWITCH ── */ .dc-sw{position:relative;width:38px;height:22px;cursor:pointer;flex-shrink:0;} .dc-sw input{opacity:0;width:0;height:0;position:absolute;} .dc-sw-t{position:absolute;inset:0;border-radius:11px;background:rgba(255,255,255,0.08);border:1.5px solid rgba(255,255,255,0.1);transition:all .16s;} .dc-sw input:checked~.dc-sw-t{background:#58cc02;border-color:#78e000;box-shadow:0 0 8px rgba(88,204,2,0.35);} .dc-sw-k{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,0.45);transition:transform .16s cubic-bezier(.34,1.56,.64,1),background .16s;box-shadow:0 1px 3px rgba(0,0,0,0.3);} .dc-sw input:checked~.dc-sw-t~.dc-sw-k{transform:translateX(16px);background:#fff;} /* ── STEPPER ── */ .dc-stepper{display:flex;align-items:center;gap:5px;} .dc-stepper button{ width:30px;height:30px;border-radius:9px; border:1.5px solid rgba(255,255,255,0.09); background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.45);font-size:15px;cursor:pointer; display:flex;align-items:center;justify-content:center; transition:all .12s;font-weight:300;line-height:1; } .dc-stepper button:hover{background:rgba(255,255,255,0.11);color:#fff;border-color:rgba(255,255,255,0.22);} .dc-stepper button:active{transform:scale(0.9);} .dc-stepper-val{min-width:34px;text-align:center;font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;font-size:13px;font-weight:600;color:#d8dae8;} /* ── ENGINE SELECTOR ── */ .dc-eng-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;padding:10px;} .dc-eng-btn{ display:flex;align-items:center;gap:10px; padding:10px 12px;border-radius:11px; border:1.5px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.03); cursor:pointer;text-align:left; transition:all .14s cubic-bezier(.34,1.56,.64,1); } .dc-eng-btn>*{pointer-events:none;} .dc-eng-btn:hover{border-color:rgba(255,255,255,0.18);background:rgba(255,255,255,0.06);transform:translateY(-1px);} .dc-eng-btn.on{border-color:rgba(88,204,2,0.45);background:rgba(88,204,2,0.08);box-shadow:0 0 12px rgba(88,204,2,0.1);} .dc-eng-icon{width:32px;height:32px;border-radius:8px;object-fit:contain;flex-shrink:0;background:rgba(0,0,0,0.2);} .dc-eng-info{display:flex;flex-direction:column;gap:2px;min-width:0;} .dc-eng-name{font-size:11px;font-weight:800;font-family:'SF Pro Rounded','Nunito',sans-serif;color:rgba(255,255,255,0.4);letter-spacing:0.03em;white-space:nowrap;} .dc-eng-btn.on .dc-eng-name{color:#78e000;} .dc-eng-sub{font-size:9.5px;color:rgba(255,255,255,0.2);font-weight:600;font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;white-space:nowrap;} .dc-eng-btn.on .dc-eng-sub{color:rgba(120,224,0,0.65);} .dc-eng-badge{ margin-left:auto;flex-shrink:0; width:7px;height:7px;border-radius:50%; background:rgba(255,255,255,0.1); } .dc-eng-badge.ready{background:#78e000;box-shadow:0 0 5px rgba(120,224,0,0.5);} /* ── LOG ── */ .dc-log-entry{ display:flex;gap:8px;align-items:baseline; padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.03); font-size:11px;font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif; } .dc-log-entry:last-child{border-bottom:none;} .dc-log-time{color:rgba(255,255,255,0.12);flex-shrink:0;font-size:9.5px;} .dc-log-src{flex-shrink:0;width:44px;font-weight:600;font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;} .dc-log-src.sys {color:rgba(255,255,255,0.22);} .dc-log-src.bot {color:#78e000;} .dc-log-src.solver{color:#a855f7;} .dc-log-msg{color:rgba(255,255,255,0.3);flex:1;word-break:break-all;} /* ── EMPTY ── */ .dc-empty{ text-align:center;color:rgba(255,255,255,0.16);font-size:12px; padding:28px 16px;line-height:2;font-family:'SF Pro Rounded','Nunito',sans-serif;font-weight:700; } /* ── PROGRESS ── */ .dc-prog-wrap{height:3px;background:rgba(255,255,255,0.05);} .dc-prog-bar{height:3px;background:linear-gradient(90deg,#58cc02,#78e000);transition:width .4s cubic-bezier(.4,0,.2,1);} /* ── HUB HEADER ── */ .dc-hub-hd{ padding:14px 14px 10px; border-bottom:1px solid rgba(255,255,255,0.05); } .dc-hub-label{font-size:10px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:rgba(255,255,255,0.2);} .dc-hub-title{font-size:20px;font-weight:900;color:#e2e4f0;margin-top:2px;font-family:'SF Pro Rounded','Nunito',sans-serif;letter-spacing:-.02em;} .dc-hub-title span{color:#78e000;} /* ── DIV ── */ .dc-divider{height:1px;background:rgba(255,255,255,0.05);margin:0 12px;} @media(max-width:520px){ #dc-panel{left:8px;right:8px;bottom:8px;width:auto;border-radius:18px;} } `; // ══════════════════════════════════════════════════════════════════════════════ // PANEL BUILD // ══════════════════════════════════════════════════════════════════════════════ let _panel = null, _toggle = null, _activeTab = "hub"; const TABS = [{ id: "hub", icon: ICONS.chess, label: "Hub" }, { id: "bot", icon: ICONS.cpu, label: "Bot" }, { id: "solver", icon: ICONS.bolt, label: "Solver" }, { id: "log", icon: ICONS.log, label: "Log" }, { id: "cfg", icon: ICONS.settings, label: "Settings" }, ]; const SRC_CLR = { correctMoves: { bg: "#0b1e0f", fg: "#4caf6e" }, highlight: { bg: "#1e1808", fg: "#e8a020" }, validPaths: { bg: "#0b0f1e", fg: "#5a6aff" }, evalFallback: { bg: "#180b1e", fg: "#a855f7" }, none: { bg: "#1e0b0b", fg: "#c44444" }, }; // Engine metadata const ENGINE_META = { stockfish: { name: "Stockfish", sub: "stockfish.online", iconUrl: "https://stockfishchess.org/images/logo/[email protected]", }, jce: { name: "js-chess-engine", sub: "esm.sh/js-chess-engine", iconUrl: null, iconSVG: ` <svg width="22" height="22" viewBox="0 0 50.8 50.8" xmlns="http://www.w3.org/2000/svg" xml:space="preserve"> <g style="stroke-width:1.00012;stroke-dasharray:none"> <path d="m29.084 17.202 4.752 3.087c.037 1.357-.699 2.623-.699 2.623H29.09c-.883 6.963 6.255 11.358 6.255 11.358 3.9 3.11 3.385 8.283 3.385 8.283s-.055.867-3.476 1.66c0 0-2.98.96-12.944.69" style="fill:none;stroke:#FFFFFF;stroke-width:3.175;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"/> <path d="m21.82 17.256-4.72 3.033c-.036 1.357.7 2.623.7 2.623h4.047c.883 6.963-6.255 11.358-6.255 11.358-3.9 3.11-3.385 8.283-3.385 8.283s.055.867 3.476 1.66c0 0 2.979.96 12.944.69" style="fill:none;stroke:#FFFFFF;stroke-width:3.175;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"/> <path d="M11.13-25.59a6.26 6.352 0 0 1-6.122 4.238A6.26 6.352 0 0 1-.81-26.011a6.26 6.352 0 0 1 2.64-7.027 6.26 6.352 0 0 1 7.397.453" style="fill:none;stroke:#FFFFFF;stroke-width:3.17506;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" transform="matrix(-.26297 .9648 -.96701 -.25472 0 0)"/> </g> </svg>`, }, }; // ── TAB: HUB ────────────────────────────────────────────────────────────────── function tabHub() { const st = BOT_S.status; const done = Math.min(SOL_STATE.currentIdx, SOL_STATE.challenges.length); const total = SOL_STATE.challenges.length; const pct = total ? Math.round(done / total * 100) : 0; const eng = activeEngineName(); const stColor = st === "playing" ? "#78e000" : st === "thinking" ? "#a855f7" : st === "our_turn" ? "#ffd900" : "rgba(255,255,255,0.25)"; return ` <div class="dc-card"> <div class="dc-hub-hd"> <div class="dc-hub-label">Active Engine</div> <div class="dc-hub-title">${esc(eng==="none"?"No engine":eng)} <span>↗</span></div> </div> <div class="dc-prog-wrap"><div class="dc-prog-bar" style="width:${pct}%"></div></div> <div class="dc-btn-row"> <button class="btn primary fill" id="dc-h-play">${ICONS.play} Play Best Move</button> <button class="btn ghost ${BOT_CFG.autoPlay?"on":""}" id="dc-h-auto">${BOT_CFG.autoPlay?ICONS.pause:ICONS.play} Auto</button> </div> </div> <div class="dc-card"> <div class="dc-btn-row"> <button class="btn green fill" id="dc-h-solve">${ICONS.bolt} Solve Next</button> <button class="btn ghost ${SOL_CFG.autoContinue?"on":""}" id="dc-h-cont">${ICONS.reload} Auto Continue</button> </div> </div> <div class="dc-card"> <div class="dc-kv-grid"> <div class="dc-kv-cell"> <div class="dc-kv-label">Engine</div> <div class="dc-kv-value accent">${esc(eng)}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Status</div> <div class="dc-kv-value" style="color:${stColor}">${esc(st.replace("_"," "))}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Playing As</div> <div class="dc-kv-value">${esc(BOT_S.playerColor??"—")}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Puzzles</div> <div class="dc-kv-value">${done} / ${total||"—"}</div> </div> </div> </div>`; } // ── TAB: BOT ───────────────────────────────────────────────────────────────── function tabBot() { const st = BOT_S.status; const side = fenSide(BOT_S.currentFen) === "w" ? "White" : "Black"; const stColor = st === "playing" ? "#78e000" : st === "thinking" ? "#a855f7" : st === "our_turn" ? "#ffd900" : "rgba(255,255,255,0.25)"; return ` <div class="dc-card"> <div class="dc-kv-grid"> <div class="dc-kv-cell"> <div class="dc-kv-label">Active Engine</div> <div class="dc-kv-value accent">${esc(activeEngineName())}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Status</div> <div class="dc-kv-value" style="color:${stColor}">${esc(st.replace("_"," "))}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Playing As</div> <div class="dc-kv-value">${esc(BOT_S.playerColor??"—")}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">To Move</div> <div class="dc-kv-value">${side}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Moves Made</div> <div class="dc-kv-value">${BOT_S.moveHistory.length}</div> </div> <div class="dc-kv-cell"> <div class="dc-kv-label">Last Move</div> <div class="dc-kv-value">${esc(BOT_S.lastMove??"—")}</div> </div> </div> </div> <div class="dc-card"> <div class="dc-btn-row"> <button class="btn primary fill" id="dc-bot-play">${ICONS.play} Play Now</button> <button class="btn ghost ${BOT_CFG.autoPlay?"on":""}" id="dc-bot-auto">${BOT_CFG.autoPlay?"Auto ON":"Auto OFF"}</button> <button class="btn ghost ${BOT_CFG.flipped?"on":""}" id="dc-bot-flip">${ICONS.flip} Flip</button> </div> </div> <div class="dc-card"> <div class="dc-fen">${esc(BOT_S.currentFen)}</div> </div>`; } // ── TAB: SOLVER ─────────────────────────────────────────────────────────────── function tabSolver() { const ch = SOL_STATE.challenges; const done = SOL_STATE.currentIdx; const total = ch.length; const pct = total ? Math.round(done / total * 100) : 0; return ` <div class="dc-card"> <div class="dc-prog-wrap"><div class="dc-prog-bar" style="width:${pct}%"></div></div> <div class="dc-btn-row"> <button class="btn primary fill" id="dc-sol-next" ${SOL_STATE.solving?"disabled":""}>${ICONS.next} Solve Next</button> <button class="btn green fill" id="dc-sol-all" ${SOL_STATE.solving?"disabled":""}>${ICONS.skipall} Solve All</button> <button class="btn ghost" id="dc-sol-reload" title="Re-fetch session from Duolingo">${ICONS.reload}</button> </div> </div> <div class="dc-card"> <div class="dc-btn-row"> <button class="btn ghost ${SOL_CFG.flipped?"on":""}" id="dc-sol-flip">${ICONS.flip} Flip Board</button> <button class="btn ghost ${SOL_CFG.autoContinue?"on":""}" id="dc-sol-cont">${ICONS.reload} Auto Continue</button> <span style="flex:1;display:flex;align-items:center;justify-content:flex-end;font-size:11px;color:rgba(255,255,255,0.2);font-family:'SF Pro Rounded','Nunito',system-ui,sans-serif;">${done} / ${total||0}</span> </div> </div> ${!ch.length ? `<div class="dc-card"><div class="dc-empty">No session loaded<br>Start a Duolingo chess lesson</div></div>` : `<div class="dc-card"><div class="dc-ch-list">${ch.map((c,i)=>{ const isCur=i===done,isDn=i<done; const clr=SRC_CLR[c.source]??SRC_CLR.none; const numCls=isCur?"current":isDn?"done":"pending"; const badge=isDn?ICONS.check:String(i+1); const moves=c.steps.map(s=>`<span class="${s.kind==="enemy"?"mv-enemy":"mv-player"}">${s.kind==="enemy"?"opp:":"our:"} ${esc(s.move)}</span>`).join(" "); return `<div class="dc-ch-item ${isCur?"current":""} ${isDn?"done":""}"> <div class="dc-ch-num ${numCls}">${badge}</div> <div class="dc-ch-body"> <span class="dc-ch-src" style="color:${clr.fg};background:${clr.bg}">${esc(c.source)}</span> <div class="dc-ch-moves">${moves||'<span class="mv-enemy">no moves</span>'}</div> </div> </div>`; }).join("")}</div></div>` }`; } // ── TAB: LOG ────────────────────────────────────────────────────────────────── function tabLog() { if (!SOL_STATE.log.length) return `<div class="dc-card"><div class="dc-empty">No activity yet</div></div>`; const rows = [...SOL_STATE.log].reverse().map(e => ` <div class="dc-log-entry"> <span class="dc-log-time">${e.time}</span> <span class="dc-log-src ${e.source}">${esc(e.source)}</span> <span class="dc-log-msg">${esc(e.msg)}</span> </div>`).join(""); return `<div class="dc-card"><div style="padding:8px 10px;">${rows}</div></div>`; } // ── TAB: SETTINGS ──────────────────────────────────────────────────────────── function tabCfg() { function sw(id, checked) { return `<label class="dc-sw"><input type="checkbox" id="${id}"${checked?" checked":""}><div class="dc-sw-t"></div><div class="dc-sw-k"></div></label>`; } function step(dn, up, val) { return `<div class="dc-stepper"><button id="${dn}">−</button><div class="dc-stepper-val" id="${val}">?</div><button id="${up}">+</button></div>`; } function engBtn(id, meta, isOn, isReady) { const icon = meta.iconUrl ? `<img class="dc-eng-icon" src="${meta.iconUrl}" alt="${meta.name}" style="background:rgba(0,0,0,0.3);padding:3px;">` : `<div class="dc-eng-icon" style="display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.06);">${meta.iconSVG}</div>`; return `<button class="dc-eng-btn${isOn?" on":""}" id="dc-eng-${id}"> ${icon} <div class="dc-eng-info"> <div class="dc-eng-name">${meta.name}</div> <div class="dc-eng-sub">${meta.sub}</div> </div> <div class="dc-eng-badge ${isReady?"ready":""}"></div> </button>`; } const sfReady = BOT_S.stockfishReady; const jceReady = BOT_S.jceReady; return ` <div class="dc-section"> <div class="dc-section-label">Engine</div> <div class="dc-card"> <div class="dc-eng-grid" style="grid-template-columns:1fr 1fr;"> ${engBtn("stockfish",ENGINE_META.stockfish, BOT_CFG.engine==="stockfish", sfReady)} ${engBtn("jce", ENGINE_META.jce, BOT_CFG.engine==="jce", jceReady)} </div> <div style="display:flex;gap:7px;padding:0 10px 10px;"> <button class="btn ghost fill" id="dc-load-sf">${ICONS.reload} Load Engine</button> <button class="btn ghost fill" id="dc-load-jce">${ICONS.reload} Load Engine</button> </div> </div> </div> <div class="dc-section"> <div class="dc-section-label">Engine Config</div> <div class="dc-card"> <div class="dc-setting-row"> <div> <div class="dc-setting-label">JCE Level</div> <div class="dc-setting-desc">js-chess-engine strength (0 – 4)</div> </div> ${step("dc-jd","dc-ju","dc-jv")} </div> <div class="dc-setting-row"> <div> <div class="dc-setting-label">Stockfish Depth</div> <div class="dc-setting-desc">stockfish.online depth (1 – 15)</div> </div> ${step("dc-sd","dc-su","dc-sv")} </div> </div> </div> <div class="dc-section"> <div class="dc-section-label">Timing (ms)</div> <div class="dc-card"> <div class="dc-setting-row"> <div class="dc-setting-label">Bot click delay</div> ${step("dc-bcd","dc-bcu","dc-bcv")} </div> <div class="dc-setting-row"> <div class="dc-setting-label">Bot move delay</div> ${step("dc-bmd","dc-bmu","dc-bmv")} </div> <div class="dc-setting-row"> <div class="dc-setting-label">Solver enemy wait</div> ${step("dc-sed","dc-seu","dc-sev")} </div> </div> </div> <div class="dc-section"> <div class="dc-section-label">Misc</div> <div class="dc-card"> <div class="dc-setting-row"> <div> <div class="dc-setting-label">Post moves to API</div> <div class="dc-setting-desc">Send move to Duolingo server</div> </div> ${sw("dc-pm",BOT_CFG.postMoves)} </div> </div> </div>`; } // ── RENDER ──────────────────────────────────────────────────────────────────── function renderPanel() { if (!_panel) return; const pane = _panel.querySelector("#dc-pane"); const tabsEl = _panel.querySelector("#dc-tabs"); const statusEl = _panel.querySelector("#dc-statusbar"); if (!pane) return; // Unlocked — show everything normally if (tabsEl) tabsEl.style.display = ""; if (statusEl) statusEl.style.display = ""; _panel.querySelectorAll(".dc-tab").forEach(t => t.classList.toggle("on", t.dataset.tab === _activeTab)); if (_activeTab === "hub") pane.innerHTML = tabHub(); else if (_activeTab === "bot") pane.innerHTML = tabBot(); else if (_activeTab === "solver") { pane.innerHTML = tabSolver(); // Auto-fetch if no session yet if (!SOL_STATE.challenges.length && !SOL_STATE._fetching) { SOL_STATE._fetching = true; _fetchSession().finally(() => { SOL_STATE._fetching = false; }); } } else if (_activeTab === "log") pane.innerHTML = tabLog(); else if (_activeTab === "cfg") pane.innerHTML = tabCfg(); wirePanel(); updateStatusBar(); } function updateStatusBar() { const bar = _panel?.querySelector("#dc-statusbar"); if (!bar) return; const st = BOT_S.status; bar.innerHTML = `<span class="dc-status-dot ${st}"></span><span class="dc-status-txt">${esc(st.replace("_"," "))}</span>${BOT_S.lastMove?`<span class="dc-status-move">${esc(BOT_S.lastMove)}</span>`:""}`; } function wirePanel() { const p = _panel; const $ = id => p.querySelector("#" + id); const on = (id, fn) => $(id)?.addEventListener("click", fn); const chk = (id, obj, key) => { const el = $(id); if (el) { el.addEventListener("change", e => { obj[key] = e.target.checked; saveSettings(); }); } }; function step(dn, up, val, obj, key, inc, min, max) { const el = $(val); if (el) el.textContent = obj[key]; on(dn, () => { obj[key] = Math.max(min, obj[key] - inc); saveSettings(); const v = $(val); if (v) v.textContent = obj[key]; }); on(up, () => { obj[key] = Math.min(max, obj[key] + inc); saveSettings(); const v = $(val); if (v) v.textContent = obj[key]; }); } // Hub on("dc-h-play", () => { BOT_CFG.autoPlay = true; saveSettings(); takeTurn(); }); on("dc-h-solve", () => solveNext().catch(e => addLog("solver", "err: " + e.message))); on("dc-h-auto", () => { BOT_CFG.autoPlay = !BOT_CFG.autoPlay; saveSettings(); renderPanel(); }); on("dc-h-cont", () => { SOL_CFG.autoContinue = !SOL_CFG.autoContinue; saveSettings(); renderPanel(); }); // Bot on("dc-bot-play", () => { BOT_CFG.autoPlay = true; saveSettings(); takeTurn(); }); on("dc-bot-auto", () => { BOT_CFG.autoPlay = !BOT_CFG.autoPlay; saveSettings(); renderPanel(); }); on("dc-bot-flip", () => { BOT_CFG.flipped = !BOT_CFG.flipped; saveSettings(); renderPanel(); }); // Solver on("dc-sol-next", () => { if (!SOL_STATE.solving) solveNext().catch(e => addLog("solver", "err: " + e.message)); }); on("dc-sol-all", () => { if (!SOL_STATE.solving) solveAll().catch(e => addLog("solver", "err: " + e.message)); }); on("dc-sol-reload", () => { addLog("solver", "Fetching session..."); _fetchSession().then(ok => { if (!ok) addLog("solver", "Could not fetch session — try navigating to a chess lesson first"); }); }); on("dc-sol-flip", () => { SOL_CFG.flipped = !SOL_CFG.flipped; saveSettings(); renderPanel(); }); on("dc-sol-cont", () => { SOL_CFG.autoContinue = !SOL_CFG.autoContinue; saveSettings(); renderPanel(); }); // Engine buttons p.querySelectorAll(".dc-eng-btn").forEach(btn => { btn.addEventListener("click", () => { const eng = btn.id.replace("dc-eng-", ""); if (["jce", "stockfish"].includes(eng)) { BOT_CFG.engine = eng; saveSettings(); renderPanel(); } }); }); on("dc-load-jce", () => { loadJCE().then(() => renderPanel()); }); on("dc-load-sf", () => { loadStockfish().then(() => renderPanel()); }); // Settings steppers step("dc-jd", "dc-ju", "dc-jv", BOT_CFG, "jceLevel", 1, 0, 4); step("dc-sd", "dc-su", "dc-sv", BOT_CFG, "stockfishDepth", 1, 1, 15); step("dc-bcd", "dc-bcu", "dc-bcv", BOT_CFG, "clickDelay", 50, 50, 2000); step("dc-bmd", "dc-bmu", "dc-bmv", BOT_CFG, "moveDelay", 100, 100, 5000); step("dc-sed", "dc-seu", "dc-sev", SOL_CFG, "enemyDelay", 100, 500, 8000); chk("dc-pm", BOT_CFG, "postMoves"); } // ══════════════════════════════════════════════════════════════════════════════ // CREATE PANEL // ══════════════════════════════════════════════════════════════════════════════ function injectCSS() { if (document.getElementById("dc-style")) return; const s = document.createElement("style"); s.id = "dc-style"; s.textContent = STYLE; document.head.appendChild(s); } function createPanel() { injectCSS(); if (_panel) { _panel.remove(); _panel = null; } _panel = document.createElement("div"); _panel.id = "dc-panel"; const tabsHTML = TABS.map(t => `<button class="dc-tab${t.id===_activeTab?" on":""}" data-tab="${t.id}">${t.icon} ${t.label}</button>`).join(""); _panel.innerHTML = ` <div id="dc-bar"> <div id="dc-wordmark"> <img class="dc-avatar" src="https://i.ibb.co/gZpNbsPP/cosmic.jpg" alt=""> <span class="dc-title-text">DuoChess</span> <span class="dc-ver-badge">1.0.0</span> </div> <button class="dc-winbtn" id="dc-minimize" title="Minimize">${ICONS.minus}</button> </div> <div id="dc-tabs">${tabsHTML}</div> <div id="dc-pane"></div> <div id="dc-statusbar" class="dc-status-bar"></div>`; document.body.appendChild(_panel); $i("dc-minimize")?.addEventListener("click", () => { const pane = _panel.querySelector("#dc-pane"); const tabs = _panel.querySelector("#dc-tabs"); const bar = _panel.querySelector("#dc-statusbar"); const hidden = pane.style.display === "none"; pane.style.display = hidden ? "" : "none"; if (tabs) tabs.style.display = hidden ? "" : "none"; if (bar) bar.style.display = hidden ? "" : "none"; }); _panel.querySelectorAll(".dc-tab").forEach(t => t.addEventListener("click", () => { if (t.dataset.tab === _activeTab) return; const pane = _panel.querySelector("#dc-pane"); // Phase 1: fade + shrink out pane.style.transition = "opacity .1s ease,transform .1s ease"; pane.style.opacity = "0"; pane.style.transform = "scale(0.97) translateY(5px)"; setTimeout(() => { // Phase 2: swap content _activeTab = t.dataset.tab; renderPanel(); // Phase 3: fade in (renderPanel replaces pane node's innerHTML but keeps the element) const p2 = _panel.querySelector("#dc-pane"); p2.style.transition = "none"; p2.style.opacity = "0"; p2.style.transform = "scale(0.97) translateY(5px)"; requestAnimationFrame(() => { p2.style.transition = "opacity .15s ease,transform .15s ease"; p2.style.opacity = "1"; p2.style.transform = "scale(1) translateY(0)"; }); }, 100); })); makeDraggable(_panel, _panel.querySelector("#dc-bar")); renderPanel(); } function $i(id) { return _panel?.querySelector("#" + id); } function makeDraggable(el, handle) { let sx = 0, sy = 0, drag = false; handle.addEventListener("pointerdown", e => { if (e.target.closest(".dc-winbtn") || e.target.closest(".dc-tab")) return; drag = true; const r = el.getBoundingClientRect(); el.style.bottom = "auto"; el.style.right = "auto"; el.style.left = r.left + "px"; el.style.top = r.top + "px"; sx = e.clientX - r.left; sy = e.clientY - r.top; handle.setPointerCapture(e.pointerId); e.preventDefault(); }); handle.addEventListener("pointermove", e => { if (!drag) return; el.style.left = Math.max(0, Math.min(e.clientX - sx, window.innerWidth - el.offsetWidth)) + "px"; el.style.top = Math.max(0, Math.min(e.clientY - sy, window.innerHeight - el.offsetHeight)) + "px"; }); handle.addEventListener("pointerup", () => { drag = false; }); } // ── KEYBOARD ────────────────────────────────────────────────────────────────── // (Alt+C toggle removed) // ══════════════════════════════════════════════════════════════════════════════ // AUTO-PLAY POLLING LOOP // Polls canvas every 600ms — triggers takeTurn when board changes & it's our turn. // Covers WebSocket moves & any API response the fetch hook might miss. // ══════════════════════════════════════════════════════════════════════════════ let _pollHash = null; let _pollRunning = false; async function _fetchMatchState() { if (!BOT_S.matchId) return; const uid = location.pathname.match(/\/(\d+)\//)?.[1] ?? "0"; const hdrs = {}; if (BOT_S.authToken) hdrs["Authorization"] = BOT_S.authToken; try { const res = await _origFetch(`/chess/1/${uid}/matches/${BOT_S.matchId}`, { method: "GET", headers: hdrs }); if (!res.ok) return; const data = await res.json(); onMatchData(data); } catch (_) {} } async function _autoPollLoop() { if (_pollRunning) return; _pollRunning = true; addLog("sys", "Auto-poll loop started"); while (true) { await sleep(600); if (!BOT_CFG.autoPlay || !BOT_S.matchId) { // still loop but skip action continue; } // detect board change via canvas hash const h = canvasHash(); if (h !== null && h !== _pollHash) { _pollHash = h; // board changed — check if it's our turn now if (BOT_S.status === "waiting") { await _fetchMatchState(); } } // safety net: if it's our turn but bot isn't acting, kick it if (BOT_S.status === "our_turn" && BOT_CFG.autoPlay) { if (BOT_S.status !== "thinking" && BOT_S.status !== "playing") { setTimeout(takeTurn, BOT_CFG.thinkDelay); } } } } // ══════════════════════════════════════════════════════════════════════════════ // Manual session inject — call from console: DuoChess.injectSession(data) window._dcInjectSession = function(data) { processSession(data); addLog("solver", "[manual] injected session: " + (data?.challenges?.length ?? "?") + " challenges"); renderPanel(); }; window.DuoChess = { solve, solveNext, solveAll, playNow: () => { BOT_CFG.autoPlay = true; takeTurn(); }, setLevel: l => { BOT_CFG.jceLevel = Number(l); saveSettings(); renderPanel(); }, setStockfishDepth: d => { BOT_CFG.stockfishDepth = Number(d); saveSettings(); renderPanel(); }, setEngine: e => { BOT_CFG.engine = e; saveSettings(); renderPanel(); }, getBestMove, setFlipped: v => { SOL_CFG.flipped = Boolean(v); saveSettings(); renderPanel(); }, setAutoContinue: v => { SOL_CFG.autoContinue = Boolean(v); saveSettings(); renderPanel(); }, panel: () => { if (!_panel) createPanel(); _panel.classList.remove("dc-hidden"); }, injectSession: window._dcInjectSession, fetchSession: _fetchSession, state: { bot: BOT_S, solver: SOL_STATE }, config: { bot: BOT_CFG, solver: SOL_CFG }, }; // ══════════════════════════════════════════════════════════════════════════════ // BOOT // ══════════════════════════════════════════════════════════════════════════════ function _boot() { loadChessJS(); loadStockfish(); loadJCE(); _autoPollLoop(); if (document.body) { createPanel(); addLog("sys", "DuoChess V1 ready"); } else { document.addEventListener("DOMContentLoaded", () => { createPanel(); addLog("sys", "DuoChess V1 ready"); }); } } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", _boot); else _boot(); })();