Last active
January 21, 2026 00:02
-
-
Save strayge/ebb7253f33f9f36aeac357383e957c0d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name RGG Chess Rewind | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2026-01-21 | |
| // @description Allow to rewind chess moves on RGG Land | |
| // @author strayge | |
| // @match https://rgg.land/checkers | |
| // @match https://rgg.land/checkers/season/s4 | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=rgg.land | |
| // @grant none | |
| // @updateURL https://gist.githubusercontent.com/strayge/ebb7253f33f9f36aeac357383e957c0d/raw/rgg_chess_rewind.user.js | |
| // @downloadURL https://gist.githubusercontent.com/strayge/ebb7253f33f9f36aeac357383e957c0d/raw/rgg_chess_rewind.user.js | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // ============================================ | |
| // CONFIGURATION & STATE | |
| // ============================================ | |
| let states = []; // Array of board states (index 0 = initial, last = current) | |
| let currentViewIndex = 0; // Currently displayed state index | |
| let moveHistory = []; // Parsed moves from log (newest first as in DOM) | |
| let boardContainer = null; // Reference to board pieces container | |
| let btnBack = null; | |
| let btnForward = null; | |
| let btnCurrent = null; | |
| let moveCounter = null; // Move counter display element | |
| // ============================================ | |
| // COORDINATE CONVERSION | |
| // ============================================ | |
| // A-S maps to columns 0-18 (left to right) | |
| // 1-19 maps to rows 18-0 (bottom to top, so row 1 = grid index 18) | |
| function coordsToGrid(coordStr) { | |
| const match = coordStr.match(/^([A-S])(\d+)$/i); | |
| if (!match) return null; | |
| const letter = match[1].toUpperCase(); | |
| const number = parseInt(match[2], 10); | |
| const col = letter.charCodeAt(0) - 'A'.charCodeAt(0); // A=0, S=18 | |
| const row = 19 - number; // 1->18, 19->0 | |
| return { col, row }; | |
| } | |
| function gridToCoords(col, row) { | |
| const letter = String.fromCharCode('A'.charCodeAt(0) + col); | |
| const number = 19 - row; | |
| return `${letter}${number}`; | |
| } | |
| // ============================================ | |
| // BOARD STATE PARSING | |
| // ============================================ | |
| function parseCurrentBoardState() { | |
| // Find the board container - it's the div with background-image of the board | |
| const boardWrapper = document.querySelector('div[style*="background-image:url(/images/checkers/board"]'); | |
| if (!boardWrapper) { | |
| console.error('RGG Rewind: Board not found'); | |
| return null; | |
| } | |
| // The pieces container is the first child div.absolute | |
| boardContainer = boardWrapper.querySelector('div.absolute'); | |
| if (!boardContainer) { | |
| console.error('RGG Rewind: Pieces container not found'); | |
| return null; | |
| } | |
| const state = new Map(); | |
| // Find all piece divs (they have img children with checker piece images) | |
| const pieceDivs = boardContainer.querySelectorAll('div.absolute'); | |
| for (const pieceDiv of pieceDivs) { | |
| const img = pieceDiv.querySelector('img[src*="/images/checkers/pieces/"]'); | |
| if (!img) continue; | |
| const style = pieceDiv.getAttribute('style'); | |
| if (!style) continue; | |
| // Parse position from style: left:calc(100% / 19 * X);top:calc(100% / 19 * Y) | |
| const leftMatch = style.match(/left:\s*calc\(100%\s*\/\s*19\s*\*\s*(\d+)\)/); | |
| const topMatch = style.match(/top:\s*calc\(100%\s*\/\s*19\s*\*\s*(\d+)\)/); | |
| if (!leftMatch || !topMatch) continue; | |
| const col = parseInt(leftMatch[1], 10); | |
| const row = parseInt(topMatch[1], 10); | |
| const coords = gridToCoords(col, row); | |
| // Parse piece type from src | |
| const src = img.getAttribute('src'); | |
| const isKing = src.includes('king-'); | |
| const colorMatch = src.match(/(man|king)-(\w+)\.webp/); | |
| if (!colorMatch) continue; | |
| const color = colorMatch[2]; // blue, yellow, red, green | |
| state.set(coords, { color, isKing }); | |
| } | |
| return state; | |
| } | |
| // Build team color mapping by simulating moves forward from current state | |
| function buildTeamColorMapping(currentBoardState, eventsData) { | |
| const mapping = {}; | |
| const boardState = new Map(currentBoardState); // Clone current state | |
| // Process events from newest to oldest (reverse to go backwards in time) | |
| for (let i = 0; i < eventsData.length; i++) { | |
| const event = eventsData[i]; | |
| if (event.type === 'move' && event.player?.teamCheckersId !== undefined) { | |
| const teamId = event.player.teamCheckersId; | |
| // Skip if we already know this team's color | |
| if (mapping[teamId]) continue; | |
| // For moves, check what piece is at the destination (current state) | |
| if (event.moveType === 'simple' || event.moveType === 'capture') { | |
| const endPos = event.endCoords?.toUpperCase(); | |
| if (endPos && boardState.has(endPos)) { | |
| const piece = boardState.get(endPos); | |
| mapping[teamId] = piece.color; | |
| } | |
| } else if (event.moveType === 'resurrection') { | |
| const pos = event.startCoords?.toUpperCase(); | |
| if (pos && boardState.has(pos)) { | |
| const piece = boardState.get(pos); | |
| mapping[teamId] = piece.color; | |
| } | |
| } | |
| } | |
| } | |
| return mapping; | |
| } | |
| // ============================================ | |
| // LOG PARSING (from embedded Next.js JSON data) | |
| // ============================================ | |
| function parseEventsFromNextData(currentBoardState) { | |
| const moves = []; | |
| // Helper to try parsing JSON with cleanup | |
| const tryParse = (str) => { | |
| try { | |
| return JSON.parse(str); | |
| } catch (e) { | |
| try { | |
| // Handle escaped quotes common in embedded JSON | |
| // This matches \" and replaces with " | |
| // Note: This is a simple heuristic and might break complex nested escaping | |
| return JSON.parse(str.replace(/\\"/g, '"')); | |
| } catch (e2) { | |
| try { | |
| // Double escape? | |
| return JSON.parse(str.replace(/\\"/g, '"').replace(/\\\\/g, '\\')); | |
| } catch (e3) { | |
| return null; | |
| } | |
| } | |
| } | |
| }; | |
| // Helper to find events array within a generic object/array | |
| const findEventsArray = (obj) => { | |
| if (!obj || typeof obj !== 'object') return null; | |
| if (Array.isArray(obj)) { | |
| // Check if this array looks like the events list | |
| // It should have objects with 'moveType' or 'startCoords' | |
| if (obj.length > 0 && obj[0] && typeof obj[0] === 'object' && ('moveType' in obj[0] || 'startCoords' in obj[0])) { | |
| return obj; | |
| } | |
| // Recursively check elements | |
| for (const item of obj) { | |
| const found = findEventsArray(item); | |
| if (found) return found; | |
| } | |
| } else { | |
| // Recursively check properties | |
| for (const key in obj) { | |
| const found = findEventsArray(obj[key]); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| }; | |
| // Helper to extract a potential JSON/Array string by scanning for brackets around a keyword | |
| const extractArrayAround = (content, keyword) => { | |
| const index = content.indexOf(keyword); | |
| if (index === -1) return null; | |
| const searchStart = Math.max(0, index - 50000); | |
| const searchEnd = Math.min(content.length, index + 50000); | |
| // Find all opening brackets before the keyword | |
| const openBrackets = []; | |
| for (let i = index; i >= searchStart; i--) { | |
| if (content[i] === '[') openBrackets.push(i); | |
| } | |
| // Try closest brackets first (innermost) | |
| for (const start of openBrackets) { | |
| let balance = 1; | |
| let inQuote = false; | |
| let currentPos = start + 1; | |
| while (currentPos < searchEnd) { | |
| const char = content[currentPos]; | |
| // Check for unescaped quote | |
| let escapeCount = 0; | |
| let backScan = currentPos - 1; | |
| while (backScan >= start && content[backScan] === '\\') { | |
| escapeCount++; | |
| backScan--; | |
| } | |
| const isEscaped = escapeCount % 2 === 1; | |
| if (char === '"' && !isEscaped) { | |
| inQuote = !inQuote; | |
| } else if (!inQuote) { | |
| if (char === '[') balance++; | |
| else if (char === ']') balance--; | |
| } | |
| if (balance === 0) { | |
| const candidate = content.substring(start, currentPos + 1); | |
| const parsed = tryParse(candidate); | |
| if (parsed) { | |
| const found = findEventsArray(parsed); | |
| if (found) return found; | |
| } | |
| break; | |
| } | |
| currentPos++; | |
| } | |
| } | |
| return null; | |
| }; | |
| // Find all script tags with Next.js data | |
| const scripts = document.querySelectorAll('script'); | |
| let eventsData = null; | |
| // Strategy 1: Look for "events" key in scripts (existing logic with robust tryParse) | |
| for (const script of scripts) { | |
| const content = script.textContent || ''; | |
| const eventsMatch = content.match(/"events":\s*(\[[\s\S]*?\])\s*[,\}]/); | |
| if (eventsMatch) { | |
| eventsData = tryParse(eventsMatch[1]); | |
| if (eventsData) break; | |
| } | |
| const eventsMatchEscaped = content.match(/\\"events\\":\s*(\[[\s\S]*?\])\s*[,\}]/); | |
| if (eventsMatchEscaped) { | |
| eventsData = tryParse(eventsMatchEscaped[1]); | |
| if (eventsData) break; | |
| } | |
| } | |
| // Strategy 2: Look for string literal containing "startCoords" in scripts (Fallback) | |
| if (!eventsData) { | |
| for (const script of scripts) { | |
| const content = script.textContent || ''; | |
| // Try regex first for speed | |
| if (content.includes('startCoords')) { | |
| const stringLiteralMatch = content.match(/"((?:\\.|[^"\\])*?startCoords(?:\\.|[^"\\])*?)"/); | |
| if (stringLiteralMatch) { | |
| const parsedRoot = tryParse(stringLiteralMatch[1]); | |
| if (parsedRoot) { | |
| eventsData = findEventsArray(parsedRoot); | |
| if (eventsData) break; | |
| } | |
| } | |
| // Fallback to bracket scanning | |
| if (!eventsData) { | |
| eventsData = extractArrayAround(content, 'startCoords'); | |
| if (eventsData) { | |
| console.log('RGG Rewind: Found events via bracket scan in script'); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Strategy 3: Check self.__next_f (with findEventsArray) | |
| if (!eventsData && typeof self !== 'undefined' && self.__next_f) { | |
| for (const item of self.__next_f) { | |
| if (Array.isArray(item) && item[1]) { | |
| const str = String(item[1]); | |
| const eventsMatch = str.match(/"events":\s*(\[[\s\S]*?\])\s*[,\}]/); | |
| if (eventsMatch) { | |
| eventsData = tryParse(eventsMatch[1]); | |
| if (eventsData) break; | |
| } | |
| if (str.includes('startCoords')) { | |
| const stringLiteralMatch = str.match(/"((?:\\.|[^"\\])*?startCoords(?:\\.|[^"\\])*?)"/); | |
| if (stringLiteralMatch) { | |
| const parsedRoot = tryParse(stringLiteralMatch[1]); | |
| if (parsedRoot) { | |
| eventsData = findEventsArray(parsedRoot); | |
| if (eventsData) break; | |
| } | |
| } | |
| if (!eventsData) { | |
| eventsData = extractArrayAround(str, 'startCoords'); | |
| if (eventsData) { | |
| console.log('RGG Rewind: Found events via bracket scan in __next_f'); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if (!eventsData || !Array.isArray(eventsData)) { | |
| // Last ditch effort: if we found an object that has an "events" property which is an array | |
| if (eventsData && typeof eventsData === 'object' && Array.isArray(eventsData.events)) { | |
| eventsData = eventsData.events; | |
| } else { | |
| console.error('RGG Rewind: Could not find events data', eventsData); | |
| return null; // Return null to signal retry needed | |
| } | |
| } | |
| console.log('RGG Rewind: Found', eventsData.length, 'events in data'); | |
| // Build team color mapping dynamically from current board and moves | |
| const TEAM_COLORS = buildTeamColorMapping(currentBoardState, eventsData); | |
| // Parse events - they are already in newest-first order | |
| for (const event of eventsData) { | |
| if (event.type === 'move') { | |
| const moveType = event.moveType; | |
| // Handle different move types | |
| switch (moveType) { | |
| case 'resurrection': | |
| { | |
| const resurrectEvent = { | |
| type: 'resurrect', | |
| position: event.startCoords?.toUpperCase(), | |
| color: null, | |
| isKing: false | |
| }; | |
| // Get color from player's team | |
| if (event.player?.teamCheckersId !== undefined) { | |
| resurrectEvent.color = TEAM_COLORS[event.player.teamCheckersId]; | |
| } else { | |
| console.warn('RGG Rewind: Unknown team for resurrection event:', event); | |
| } | |
| if (resurrectEvent.position) { | |
| moves.push(resurrectEvent); | |
| } | |
| } | |
| break; | |
| case 'bomb': | |
| { | |
| const bombEvent = { | |
| type: 'bomb', | |
| position: event.startCoords?.toUpperCase(), | |
| bombedColor: null, | |
| bombedIsKing: false | |
| }; | |
| if (bombEvent.position) { | |
| moves.push(bombEvent); | |
| } | |
| } | |
| break; | |
| case 'capture': | |
| { | |
| const move = { | |
| type: 'move', | |
| from: event.startCoords?.toUpperCase(), | |
| to: event.endCoords?.toUpperCase(), | |
| color: null, | |
| capture: event.captureCoords?.toUpperCase() || null, | |
| captureColor: null, | |
| captureIsKing: false | |
| }; | |
| // Get color from player's team | |
| if (event.player?.teamCheckersId !== undefined) { | |
| move.color = TEAM_COLORS[event.player.teamCheckersId]; | |
| } else { | |
| console.warn('RGG Rewind: Unknown team for capture event:', event); | |
| } | |
| // Get color from captureTeam.checkersId | |
| if (event.captureTeam?.checkersId !== undefined) { | |
| move.captureColor = TEAM_COLORS[event.captureTeam.checkersId]; | |
| } else { | |
| console.warn('RGG Rewind: Unknown team for capture event:', event); | |
| } | |
| // Check if it was a king | |
| move.captureIsKing = event.captureIsKing || false; | |
| if (move.from && move.to) { | |
| moves.push(move); | |
| } | |
| } | |
| break; | |
| case 'simple': | |
| { | |
| // Regular move without capture | |
| const move = { | |
| type: 'move', | |
| from: event.startCoords?.toUpperCase(), | |
| to: event.endCoords?.toUpperCase(), | |
| color: null, | |
| capture: null, | |
| captureColor: null, | |
| captureIsKing: false | |
| }; | |
| // Get color from player's team | |
| if (event.player?.teamCheckersId !== undefined) { | |
| move.color = TEAM_COLORS[event.player.teamCheckersId]; | |
| } else { | |
| console.warn('RGG Rewind: Unknown team for simple move event:', event); | |
| } | |
| if (move.from && move.to) { | |
| moves.push(move); | |
| } | |
| } | |
| break; | |
| default: | |
| console.warn('RGG Rewind: Unknown moveType:', moveType, 'for event:', event); | |
| break; | |
| } | |
| } else if (event.type === 'game') { | |
| // Ignore game start/end events | |
| continue; | |
| } else { | |
| console.warn('RGG Rewind: Unknown event type:', event.type, 'for event:', event); | |
| } | |
| } | |
| // Second pass: enrich bomb events with actual piece information | |
| // by tracking the last known piece at each position | |
| enrichEvents(moves); | |
| return moves; | |
| } | |
| // Helper to check if a move enters the promotion zone | |
| function isPromotionZone(coords, color) { | |
| const grid = coordsToGrid(coords); | |
| if (!grid) return false; | |
| switch (color) { | |
| case 'red': return grid.row === 0; | |
| case 'blue': return grid.row === 18; | |
| case 'green': return grid.col === 18; | |
| case 'yellow': return grid.col === 0; | |
| default: return false; | |
| } | |
| } | |
| // Enrich bomb events with piece info by tracking last known piece at each position | |
| function enrichEvents(moves) { | |
| // Track last known piece at each position | |
| const lastPieceAt = new Map(); // position -> {color, isKing} | |
| // Initialize with starting positions (for pieces bombed before first move) | |
| // Triangle formation for 4-player checkers | |
| const initializeStartingPositions = () => { | |
| // Red pieces (bottom) - triangle pointing up | |
| const redPositions = [ | |
| 'F1', 'H1', 'J1', 'L1', 'N1', | |
| 'G2', 'I2', 'K2', 'M2', | |
| 'H3', 'J3', 'L3', | |
| 'I4', 'K4', | |
| 'J5' | |
| ]; | |
| // Blue pieces (top) - triangle pointing down | |
| const bluePositions = [ | |
| 'F19', 'H19', 'J19', 'L19', 'N19', | |
| 'G18', 'I18', 'K18', 'M18', | |
| 'H17', 'J17', 'L17', | |
| 'I16', 'K16', | |
| 'J15' | |
| ]; | |
| // Green pieces (left) - triangle pointing right | |
| const greenPositions = [ | |
| 'A6', 'A8', 'A10', 'A12', 'A14', | |
| 'B7', 'B9', 'B11', 'B13', | |
| 'C8', 'C10', 'C12', | |
| 'D9', 'D11', | |
| 'E10' | |
| ]; | |
| // Yellow pieces (right) - triangle pointing left | |
| const yellowPositions = [ | |
| 'S6', 'S8', 'S10', 'S12', 'S14', | |
| 'R7', 'R9', 'R11', 'R13', | |
| 'Q8', 'Q10', 'Q12', | |
| 'P9', 'P11', | |
| 'O10' | |
| ]; | |
| redPositions.forEach(pos => lastPieceAt.set(pos, { color: 'red', isKing: false })); | |
| bluePositions.forEach(pos => lastPieceAt.set(pos, { color: 'blue', isKing: false })); | |
| greenPositions.forEach(pos => lastPieceAt.set(pos, { color: 'green', isKing: false })); | |
| yellowPositions.forEach(pos => lastPieceAt.set(pos, { color: 'yellow', isKing: false })); | |
| }; | |
| initializeStartingPositions(); | |
| // Process moves in reverse (oldest to newest) | |
| for (let i = moves.length - 1; i >= 0; i--) { | |
| const move = moves[i]; | |
| if (move.type === 'move') { | |
| const piece = lastPieceAt.get(move.from); | |
| if (piece) { | |
| // Check for promotion | |
| if (!piece.isKing && isPromotionZone(move.to, piece.color)) { | |
| move.isPromotion = true; | |
| piece.isKing = true; | |
| } | |
| // Move piece | |
| lastPieceAt.delete(move.from); | |
| lastPieceAt.set(move.to, { ...piece }); | |
| } | |
| if (move.capture) { | |
| lastPieceAt.delete(move.capture); | |
| } | |
| } else if (move.type === 'resurrect') { | |
| // Remember resurrected piece | |
| lastPieceAt.set(move.position, { | |
| color: move.color, | |
| isKing: move.isKing | |
| }); | |
| } else if (move.type === 'bomb') { | |
| // Enrich bomb with last known piece at this position | |
| const piece = lastPieceAt.get(move.position); | |
| if (piece) { | |
| move.bombedColor = piece.color; | |
| move.bombedIsKing = piece.isKing; | |
| lastPieceAt.delete(move.position); | |
| } | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // STATE COMPUTATION | |
| // ============================================ | |
| function cloneState(state) { | |
| const newState = new Map(); | |
| for (const [coords, piece] of state) { | |
| newState.set(coords, { ...piece }); | |
| } | |
| return newState; | |
| } | |
| function computeAllStates(currentState, moves) { | |
| const statesArray = []; | |
| let state = cloneState(currentState); | |
| statesArray.push(cloneState(state)); | |
| for (const move of moves) { | |
| if (move.type === 'move') { | |
| const piece = state.get(move.to); | |
| if (piece) { | |
| // Update piece if it was a promotion | |
| if (move.isPromotion) { | |
| piece.isKing = false; | |
| } | |
| state.delete(move.to); | |
| state.set(move.from, piece); | |
| if (move.capture && move.captureColor) { | |
| state.set(move.capture, { | |
| color: move.captureColor, | |
| isKing: move.captureIsKing | |
| }); | |
| } | |
| } | |
| } else if (move.type === 'resurrect') { | |
| // Unapply resurrect: remove the piece that was added | |
| state.delete(move.position); | |
| } else if (move.type === 'bomb') { | |
| // Unapply bomb: restore the piece that was removed | |
| if (move.bombedColor) { | |
| state.set(move.position, { | |
| color: move.bombedColor, | |
| isKing: move.bombedIsKing | |
| }); | |
| } else { | |
| // If we don't know the color, we can't fully restore the state | |
| // This is a limitation - we'd need more data from the event | |
| console.warn('RGG Rewind: Cannot fully restore bomb at', move.position, '- unknown piece color'); | |
| } | |
| } | |
| statesArray.push(cloneState(state)); | |
| } | |
| statesArray.reverse(); | |
| return statesArray; | |
| } | |
| // ============================================ | |
| // BOARD RENDERING | |
| // ============================================ | |
| function renderState(stateIndex) { | |
| if (!boardContainer || stateIndex < 0 || stateIndex >= states.length) return; | |
| const state = states[stateIndex]; | |
| currentViewIndex = stateIndex; | |
| // Remove all existing piece divs (but keep coordinate overlay) | |
| const existingPieces = boardContainer.querySelectorAll('div.absolute'); | |
| for (const pieceDiv of existingPieces) { | |
| // Check if this is a piece div (has img with piece image) | |
| const img = pieceDiv.querySelector('img[src*="/images/checkers/pieces/"]'); | |
| // Check if this is a highlight overlay | |
| const isHighlight = pieceDiv.classList.contains('highlight-overlay'); | |
| if (img || isHighlight) { | |
| pieceDiv.remove(); | |
| } | |
| } | |
| // Helper to add highlight | |
| const addHighlight = (coords) => { | |
| const grid = coordsToGrid(coords); | |
| if (!grid) return; | |
| const div = document.createElement('div'); | |
| div.className = 'absolute highlight-overlay'; | |
| div.style.cssText = `height:calc(100% / 19);left:calc(100% / 19 * ${grid.col});top:calc(100% / 19 * ${grid.row});width:calc(100% / 19);background-color:rgba(255, 235, 59, 0.5);mix-blend-mode:hard-light;pointer-events:none;z-index:0;`; | |
| // Insert before coordinate overlay (last child) | |
| const coordOverlay = boardContainer.querySelector('div.absolute.inset-0'); | |
| if (coordOverlay) { | |
| boardContainer.insertBefore(div, coordOverlay); | |
| } else { | |
| boardContainer.appendChild(div); | |
| } | |
| }; | |
| // Render highlights for the move that created this state | |
| if (stateIndex > 0 && moveHistory.length > 0) { | |
| // states[0] is initial. states[1] is after move N (where N is moves.length) | |
| // wait, moves are reversed (newest at 0). | |
| // so states[1] is result of oldest move (index moves.length - 1) | |
| // states[states.length - 1] is result of newest move (index 0) | |
| const moveIndex = moveHistory.length - stateIndex; | |
| if (moveIndex >= 0 && moveIndex < moveHistory.length) { | |
| const move = moveHistory[moveIndex]; | |
| if (move.type === 'move') { | |
| if (move.from) addHighlight(move.from); | |
| if (move.to) addHighlight(move.to); | |
| } else if (move.type === 'resurrect' || move.type === 'bomb' || move.type === 'promotion') { | |
| if (move.position) addHighlight(move.position); | |
| } | |
| } | |
| } | |
| // Create new piece divs for current state | |
| for (const [coords, piece] of state) { | |
| const grid = coordsToGrid(coords); | |
| if (!grid) continue; | |
| const pieceDiv = document.createElement('div'); | |
| pieceDiv.className = 'absolute'; | |
| pieceDiv.style.cssText = `height:calc(100% / 19);left:calc(100% / 19 * ${grid.col});top:calc(100% / 19 * ${grid.row});width:calc(100% / 19)`; | |
| const img = document.createElement('img'); | |
| const pieceType = piece.isKing ? 'king' : 'man'; | |
| img.alt = `шашка`; | |
| img.className = 'object-contain size-full'; | |
| img.src = `/images/checkers/pieces/${pieceType}-${piece.color}.webp`; | |
| pieceDiv.appendChild(img); | |
| // Insert before coordinate overlay (last child) | |
| const coordOverlay = boardContainer.querySelector('div.absolute.inset-0'); | |
| if (coordOverlay) { | |
| boardContainer.insertBefore(pieceDiv, coordOverlay); | |
| } else { | |
| boardContainer.appendChild(pieceDiv); | |
| } | |
| } | |
| updateButtonStates(); | |
| } | |
| // ============================================ | |
| // UI CONTROLS | |
| // ============================================ | |
| function updateButtonStates() { | |
| if (!btnBack || !btnForward || !btnCurrent) return; | |
| const atStart = currentViewIndex <= 0; | |
| const atEnd = currentViewIndex >= states.length - 1; | |
| btnBack.disabled = atStart; | |
| btnBack.style.opacity = atStart ? '0.4' : '1'; | |
| btnBack.style.cursor = atStart ? 'not-allowed' : 'pointer'; | |
| btnForward.disabled = atEnd; | |
| btnForward.style.opacity = atEnd ? '0.4' : '1'; | |
| btnForward.style.cursor = atEnd ? 'not-allowed' : 'pointer'; | |
| btnCurrent.disabled = atEnd; | |
| btnCurrent.style.opacity = atEnd ? '0.4' : '1'; | |
| btnCurrent.style.cursor = atEnd ? 'not-allowed' : 'pointer'; | |
| // Update move counter | |
| if (moveCounter) { | |
| moveCounter.textContent = `${currentViewIndex} / ${states.length - 1}`; | |
| } | |
| } | |
| function createUI() { | |
| // Find the log title | |
| const headings = document.querySelectorAll('h6.MuiTypography-h6'); | |
| let logTitle = null; | |
| for (const h of headings) { | |
| if (h.textContent.includes('Лог событий')) { | |
| logTitle = h; | |
| break; | |
| } | |
| } | |
| if (!logTitle) { | |
| console.error('RGG Rewind: Log title not found'); | |
| return; | |
| } | |
| // Create button container | |
| const container = document.createElement('div'); | |
| container.style.cssText = ` | |
| display: flex; | |
| justify-content: center; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| `; | |
| // Button styles | |
| const buttonStyle = ` | |
| padding: 6px 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| border-radius: 8px; | |
| background: transparent; | |
| color: #fff; | |
| font-size: 16px; | |
| cursor: pointer; | |
| transition: background-color 0.15s, opacity 0.15s; | |
| `; | |
| // Back button | |
| btnBack = document.createElement('button'); | |
| btnBack.innerHTML = '◀'; | |
| btnBack.title = 'Предыдущий ход'; | |
| btnBack.style.cssText = buttonStyle; | |
| btnBack.onmouseenter = () => { if (!btnBack.disabled) btnBack.style.backgroundColor = 'rgba(255,255,255,0.08)'; }; | |
| btnBack.onmouseleave = () => { btnBack.style.backgroundColor = 'transparent'; }; | |
| btnBack.onclick = () => { | |
| if (currentViewIndex > 0) { | |
| renderState(currentViewIndex - 1); | |
| } | |
| }; | |
| // Forward button | |
| btnForward = document.createElement('button'); | |
| btnForward.innerHTML = '▶'; | |
| btnForward.title = 'Следующий ход'; | |
| btnForward.style.cssText = buttonStyle; | |
| btnForward.onmouseenter = () => { if (!btnForward.disabled) btnForward.style.backgroundColor = 'rgba(255,255,255,0.08)'; }; | |
| btnForward.onmouseleave = () => { btnForward.style.backgroundColor = 'transparent'; }; | |
| btnForward.onclick = () => { | |
| if (currentViewIndex < states.length - 1) { | |
| renderState(currentViewIndex + 1); | |
| } | |
| }; | |
| // Current state button | |
| btnCurrent = document.createElement('button'); | |
| btnCurrent.innerHTML = '⏹'; | |
| btnCurrent.title = 'Текущее состояние'; | |
| btnCurrent.style.cssText = buttonStyle; | |
| btnCurrent.onmouseenter = () => { if (!btnCurrent.disabled) btnCurrent.style.backgroundColor = 'rgba(255,255,255,0.08)'; }; | |
| btnCurrent.onmouseleave = () => { btnCurrent.style.backgroundColor = 'transparent'; }; | |
| btnCurrent.onclick = () => { | |
| if (currentViewIndex !== states.length - 1) { | |
| renderState(states.length - 1); | |
| } | |
| }; | |
| container.appendChild(btnBack); | |
| container.appendChild(btnForward); | |
| container.appendChild(btnCurrent); | |
| // Move counter | |
| moveCounter = document.createElement('div'); | |
| moveCounter.style.cssText = ` | |
| color: rgba(255, 255, 255, 0.7); | |
| font-size: 14px; | |
| padding: 6px 12px; | |
| display: flex; | |
| align-items: center; | |
| `; | |
| moveCounter.textContent = `${currentViewIndex} / ${states.length - 1}`; | |
| container.appendChild(moveCounter); | |
| // Insert after the log title | |
| logTitle.insertAdjacentElement('afterend', container); | |
| updateButtonStates(); | |
| } | |
| // ============================================ | |
| // INITIALIZATION | |
| // ============================================ | |
| function initialize(retryCount = 0) { | |
| console.log('RGG Rewind: Initializing... (attempt', retryCount + 1, ')'); | |
| // Parse current board state | |
| const currentState = parseCurrentBoardState(); | |
| if (!currentState || currentState.size === 0) { | |
| console.error('RGG Rewind: Could not parse board state'); | |
| return; | |
| } | |
| console.log('RGG Rewind: Parsed', currentState.size, 'pieces'); | |
| // Parse move history from embedded data | |
| moveHistory = parseEventsFromNextData(currentState); | |
| // If data not found and we haven't retried too many times, try again | |
| if (moveHistory === null && retryCount < 5) { | |
| console.log('RGG Rewind: Events data not ready, retrying in 1s...'); | |
| setTimeout(() => initialize(retryCount + 1), 1000); | |
| return; | |
| } | |
| if (moveHistory === null) { | |
| console.error('RGG Rewind: Could not find events data after multiple retries'); | |
| return; | |
| } | |
| console.log('RGG Rewind: Parsed', moveHistory.length, 'moves'); | |
| // for (let i = moveHistory.length - 1; i >= 0; i--) { | |
| // console.log('RGG Rewind: Move', moveHistory.length - i, ':', moveHistory[i]); | |
| // } | |
| // Compute all states | |
| states = computeAllStates(currentState, moveHistory); | |
| currentViewIndex = states.length - 1; // Start at current state | |
| console.log('RGG Rewind: Computed', states.length, 'states'); | |
| // Create UI | |
| createUI(); | |
| console.log('RGG Rewind: Initialization complete'); | |
| } | |
| // Wait for page to load | |
| function waitForPage() { | |
| const board = document.querySelector('div[style*="background-image:url(/images/checkers/board"]'); | |
| const log = document.querySelector('div[data-testid="virtuoso-item-list"]'); | |
| const logTitle = Array.from(document.querySelectorAll('h6.MuiTypography-h6')).find(h => h.textContent.includes('Лог событий')); | |
| if (board && log && logTitle) { | |
| // Additional delay to ensure React hydration is complete | |
| setTimeout(() => { | |
| // Check one more time before initializing | |
| const stillExists = document.querySelector('div[style*="background-image:url(/images/checkers/board"]'); | |
| if (stillExists) { | |
| initialize(); | |
| } else { | |
| // React re-rendered, wait and try again | |
| setTimeout(waitForPage, 500); | |
| } | |
| }, 1500); | |
| } else { | |
| setTimeout(waitForPage, 500); | |
| } | |
| } | |
| // Start | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', waitForPage); | |
| } else { | |
| waitForPage(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment