Automagic Jigidi Solver

Fastest way of solving Jigidi puzzles

// ==UserScript==
// @name        Automagic Jigidi Solver
// @description Fastest way of solving Jigidi puzzles
// @match       *://jigidi.com/*
// @match       *://www.jigidi.com/*
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @grant       GM_xmlhttpRequest
// @version     1.9.6
// @author      Louhikoru
// @require     https://openuserjs.org/src/libs/sizzle/GM_config.js
// @namespace   https://greasyfork.org/en/scripts/394279
// @homepageURL https://greasyfork.org/en/scripts/394279
// @supportURL  https://greasyfork.org/en/scripts/394279/feedback
// @license     The Coffeeware License
// @run-at      document-start
// ==/UserScript==
(function() {
    'use strict'

    const BLOCKED = 'javascript/blocked'
    const THUMB_IMG = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3R5b'+
          'GU9Ii13ZWJraXQtdHJhbnNmb3JtLW9yaWdpbjo1MCUgNTAlOy13ZWJraXQtYW5pbWF0aW9uOnNwaW4gMS41cyBsaW5lYXIgaW5maW5pdGU7LXdlYmtpdC1iYWNr'+
          'ZmFjZS12aXNpYmlsaXR5OmhpZGRlbjthbmltYXRpb246c3BpbiAxLjVzIGxpbmVhciBpbmZpbml0ZSIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV'+
          '2ZW5vZGQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxLjQiPjxkZWZzPjxzdHlsZT48IVtDREFUQVtALXdlYmtpdC1rZXlmcm'+
          'FtZXMgc3Bpbntmcm9tey13ZWJraXQtdHJhbnNmb3JtOnJvdGF0ZSgwZGVnKX10b3std2Via2l0LXRyYW5zZm9ybTpyb3RhdGUoLTM1OWRlZyl9fUBrZXlmcmFtZ'+
          'XMgc3Bpbntmcm9te3RyYW5zZm9ybTpyb3RhdGUoMGRlZyl9dG97dHJhbnNmb3JtOnJvdGF0ZSgtMzU5ZGVnKX19XV0+PC9zdHlsZT48L2RlZnM+PGcgaWQ9Im91'+
          'dGVyIj48cGF0aCBkPSJNMjAgMGE0IDQgMCAxMTAgOCA0IDQgMCAwMTAtOHoiLz48cGF0aCBkPSJNNiA2YTQgNCAwIDExNiA2IDQgNCAwIDAxLTYtNnoiIGZpbGw'+
          '9IiNkMmQyZDIiLz48cGF0aCBkPSJNMjAgMzJhNCA0IDAgMTEwIDggNCA0IDAgMDEwLTh6IiBmaWxsPSIjODI4MjgyIi8+PHBhdGggZD0iTTI4IDI4YTQgNCAwID'+
          'ExNiA2IDQgNCAwIDAxLTYtNnoiIGZpbGw9IiM2NTY1NjUiLz48cGF0aCBkPSJNNCAxNmE0IDQgMCAxMTAgOCA0IDQgMCAwMTAtOHoiIGZpbGw9IiNiYmIiLz48c'+
          'GF0aCBkPSJNNiAyOGE0IDQgMCAxMTYgNiA0IDQgMCAwMS02LTZ6IiBmaWxsPSIjYTRhNGE0Ii8+PHBhdGggZD0iTTM2IDE2YTQgNCAwIDExMCA4IDQgNCAwIDAx'+
          'MC04eiIgZmlsbD0iIzRhNGE0YSIvPjxwYXRoIGQ9Ik0yOCA2YTQgNCAwIDExNiA2IDQgNCAwIDAxLTYtNnoiIGZpbGw9IiMzMjMyMzIiLz48L2c+PC9zdmc+'

    const config_title = document.createElement('a')
    config_title.target = '_blank'
    config_title.textContent = 'Automagic Jigidi Solver'
    config_title.href = 'https://greasyfork.org/en/scripts/394279'
    config_title.rel = 'noopener noreferrer'

    GM_config.init({
        'id': 'AJS',
        'title': config_title,
        'fields': {
            'block_ads': {
                'section': 'Page settings',
                'label': 'Block ads and tracking scripts',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': true
            },
            'hide_ads': {
                'label': 'Hide ad elements',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': true
            },
            'copy_button': {
                'section': 'Puzzle settings',
                'label': 'Add "Copy" button to completion message dialog',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': true
            },
            'pause_blur': {
                'label': 'Blur puzzle image when paused',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': false
            },
            'change_image': {
                'label': 'Easy puzzle - replace puzzle image with easy image',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': false
            },
            'stack_pieces': {
                'label': 'Stack pieces - DanQ method to disable shuffling',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': false
            },
            'no_message': {
                'label': 'Warn if puzzle has no completion messsage',
                'labelPos' : 'right',
                'type': 'checkbox',
                'default': true
            }
        },
        'css':
        '#AJS .field_label { padding-left: 6px; }\n'+
        '#AJS .config_header { font-size: 16pt; padding: 0px 10px; }\n'+
        '#AJS .config_var { padding: 8px 8px; }'
    })

    const block_ads = GM_config.get('block_ads')
    const no_message = GM_config.get('no_message')
    const debug = false
    const copy_id = randomId()
    const solve_id = randomId()
    const trap = new Map()

    let   block_load  = true
    let   dialog      = null
    let   game_js_src = ''
    let   game_js_sri = ''
    let   game_js_ver = ''

    const functionToString = Function.prototype.toString
    Function.prototype.toString = function toString() {
        if (this) {
            if (this == Function.prototype.toString)
                return functionToString.call(functionToString)
            const self = trap.get(this)
            if (self)
                return functionToString.call(self)
        }
        return functionToString.call(this)
    }

    function addScript(text) {
        const head = document.getElementsByTagName('head')[0] || document.documentElement
        const script = document.createElement('script')
        script.type = 'text/javascript'
        script.appendChild(document.createTextNode(text))
        head.appendChild(script)
        return script
    }

    /* Firefox has this additional event which prevents scripts from beeing executed */
    const beforeScriptExecuteListener = (event)=>{
        const node = event.target
        if (node.getAttribute('type') === BLOCKED) {
            event.preventDefault()
            event.stopPropagation()
        }
        node.removeEventListener('beforescriptexecute', beforeScriptExecuteListener)
    }

    function clearFunction(str, regex) {
        const pos = regex.exec(str)
        if (!pos)
            return str
        function matchClosure(str, pos) {
            const rx = /\{|\}/g
            rx.lastIndex = pos + 1
            let depth = 1
            while (pos = rx.exec(str))
                if (!(depth += str[pos.index] === '{' ? 1 : -1 ))
                    return pos.index
            return -1
        }
        const begin = pos.index + pos[0].length
        const end = matchClosure(str, begin)
        return (end < begin) ? str : str.slice(0, begin) + str.slice(end)
    }

    function clearNamedFunction(str, name) {
        return clearFunction(str, new RegExp('function\\s+'+name+'\\([^\)]*\\)[^\{]*\{'))
    }

    function copyButtonDisplay(visible) {
        const copy_button = document.getElementById(copy_id)
        if (copy_button)
            copy_button.style.display = visible ? 'inline-block' : 'none'
    }

    function detour(self, func) {
        let detour = function() {
            try {
                func.apply(this, arguments)
                return self.apply(this, arguments)
            } catch (err) {
                log('Detour caught error: ' + err.message)
            }
        }
        try {
            const name = 'name' in self ? self.name : ''
            Object.defineProperty(detour, 'name', { value: name })
        } catch (err) {
            log('Detour caught error: ' + err.message)
        }
        trap.set(detour, self)
        return detour
    }

    function isArrowFn(fn) {
        return (typeof fn === 'function') && !/^(?:(?:\/\*[^(?:\*\/)]*\*\/\s*)|(?:\/\/[^\r\n]*))*\s*(?:(?:(?:async\s(?:(?:\/\*[^(?:\*\/)]*\*\/\s*)|(?:\/\/[^\r\n]*))*\s*)?function|class)(?:\s|(?:(?:\/\*[^(?:\*\/)]*\*\/\s*)|(?:\/\/[^\r\n]*))*)|(?:[_$\w][\w0-9_$]*\s*(?:\/\*[^(?:\*\/)]*\*\/\s*)*\s*\())/.test(functionToString.call(fn))
    }

    function loadScript(src, sri) {
        const head = document.getElementsByTagName('head')[0] || document.documentElement
        const script = document.createElement('script')
        script.type = 'text/javascript'
        if (sri)
            script.integrity = sri
        script.src = src
        head.appendChild(script)
        return script
    }

    function log(str) {
        debug && console.log(str)
    }

    function randn(mean, stdev) {
        let cached = true, y
        return ()=>{
            if (cached = !cached)
                return mean + y
            let x, r
            do {
                x = 2.0 * Math.random() - 1.0, y = 2.0 * Math.random() - 1.0, r = x * x + y * y
            } while (r >= 1.0 || r == 0.0)
                return r = stdev * Math.sqrt(-2.0 * Math.log(r) / r), y *= r, mean + x * r
        }
    }

    function randomString(len) {
        let str = ''
        for (let i = 0; i < len; i++) {
            let rand = Math.floor(Math.random() * 62)
            str += String.fromCharCode(rand += rand > 9 && (rand < 36 && 55 || 61) || 48)
        }
        return str
    }

    function randomId() {
        return randomString(Math.random() * 5 + 5)
    }

    /* There are other ways of implementing parts of this, but this is as minimal as it can be.
     * Jigidi uses Websocket to communicate each step to server, and server side maintains and monitors
     * state changes. It is possible that they will introduce speed limits but that is not a big problem.
     * We can also introduce randomness to movement/timings to better mimic human.
     *
     * Again to maintain exact style of Jigidi, our internal code is also obfuscated as the original.
     */
    function modifyGame_v3_1955(game_js) {
        let mod_count = 0, mod_total = 0

        /* Change puzzle image */
        if (GM_config.get('change_image')) {
            const thumb = document.getElementById('preview-img')
            if (thumb)
                thumb.src = THUMB_IMG
            game_js = game_js.replace('lookup.php",b).',()=>{
                mod_count++
                return 'lookup.php",b).then((a)=>{let b=a.fallback;jigidi.image_src=a.image;const c=document.createElement("canvas");'+
                    'const t=c.getContext("2d");c.height=b.px_height;c.width=b.px_width;const w=b.px_width/b.x_count,'+
                    'h=b.px_height/b.y_count,d=["#c65300","#0066b3","#773494","#ce9776","#b1c609","#ddb8f9","#9c585a",'+
                    '"#00c492","#bb7200","#9d98ff","#99292f","#6a4e00","#ff5e81","#c6001f","#0ee275","#a38ec2","#5f38bc",'+
                    '"#ff5656","#0190a8","#c73204","#425b8c","#a70038","#156015","#e66ff5","#01b6a8","#ffa051","#b60072",'+
                    '"#963010","#008a64","#5bbf30"];t.font="bold "+Math.floor(0.3*h)+"px Verdana",t.textAlign="center",'+
                    't.textBaseline="middle";if(b.y_count<=b.x_count){for(let i=0;i<b.y_count;++i){t.fillStyle=d[i%30];'+
                    't.fillRect(0,h*i,b.px_width,h*(i+1));t.fillStyle="#000";for(let j=0;j<b.x_count;++j){t.fillText(j+1,'+
                    'w*(j+0.5),h*(i+0.5))}}}else{for(let i=0;i<b.x_count;++i){t.fillStyle=d[i%30];t.fillRect(w*i,0,w*(i+1),'+
                    'b.px_height);t.fillStyle="#000";for(let j=0;j<b.y_count;++j){t.fillText(j+1,w*(i+0.5),h*(j+0.5))}}}'+
                    'a.image=c.toDataURL();b=document.getElementById("preview-img");if(b)b.src=a.image;return a}).'
            })
        } else {
            game_js = game_js.replace('"loaded");ed',()=>{
                mod_count++
                return '"loaded");jigidi.image_src=e.image;ed'
            })
        }
        mod_total++

        /* Enable logging */
        if (debug) {
            game_js = game_js.replace('));d.logToConsole',()=>{
                mod_count++
                return '));true'
            })
            mod_total++
        }

        /* Stack pieces */
        if (GM_config.get('stack_pieces')) {
            game_js = game_js.replace('<a.G',()=>{
                mod_count++
                return '<!a.K*a.G'
            })
            mod_total++
        }

        /* Disable blur when paused */
        if (!GM_config.get('pause_blur')) {
            game_js = game_js.replace('a.h.style.filter="blur(20px)"',()=>{
                mod_count++
                return 'a.h.style.filter=null'
            })
            mod_total++
        }

        /* Inject solver */
        game_js = game_js.replace('this.fullscreen={',()=>{
            mod_count++
            return 'this.solver=function(){if(!this.solver_run)return;let f=0,B=a.A.h,D=B.g;function tc(c,dx,dy){'+
                'let n=kb(D,c.index.x+dx,c.index.y+dy);if(n<0)return 1;n=D.U(n);if(c.group.id===n.group.id)return 1;'+
                'dx=n.position.x-dx*D.D.width;dy=n.position.y-dy*D.D.height;pb(D,c.id);nb(D,c.id,dx,dy);Ab(B,c.id);Gb(B,c.id);'+
                'a.h.update();return!(f=c.group.id===n.group.id)}function tp(c){return tc(c,-1,0)&&tc(c,0,1)&&tc(c,1,0)&&'+
                'tc(c,0,-1)}Eb(B,tp);f&&D.G>10?setTimeout(function(){this.solver();}.bind(this),0):this.solve(0)};this.fullscreen={'
        })
        mod_total++

        /* Hide mod */
        game_js = game_js.replace('c(this)', ()=>{
            mod_count++
            return '4'
        })
        mod_total++
        game_js = game_js.replace('c(p.oc)', ()=>{
            mod_count++
            return '108'
        })
        mod_total++

        return mod_count == mod_total ? game_js : ''
    }

    function modifyGame_v3_2284(game_js) {
        let mod_count = 0, mod_total = 0

        /* Change puzzle image */
        if (GM_config.get('change_image')) {
            const thumb = document.getElementById('preview-img')
            if (thumb)
                thumb.src = THUMB_IMG
            game_js = game_js.replace('a.Fb=b.image;',()=>{
                mod_count++
                return 'jigidi.image_src=b.image;const d=document.createElement("canvas");'+
                    'const t=d.getContext("2d");d.height=c.px_height;d.width=c.px_width;const w=c.px_width/c.x_count,'+
                    'h=c.px_height/c.y_count,e=["#c65300","#0066b3","#773494","#ce9776","#b1c609","#ddb8f9","#9c585a",'+
                    '"#00c492","#bb7200","#9d98ff","#99292f","#6a4e00","#ff5e81","#c6001f","#0ee275","#a38ec2","#5f38bc",'+
                    '"#ff5656","#0190a8","#c73204","#425b8c","#a70038","#156015","#e66ff5","#01b6a8","#ffa051","#b60072",'+
                    '"#963010","#008a64","#5bbf30"];t.font="bold "+Math.floor(0.3*h)+"px Verdana",t.textAlign="center",'+
                    't.textBaseline="middle";if(c.y_count<=c.x_count){for(let i=0;i<c.y_count;++i){t.fillStyle=e[i%30];'+
                    't.fillRect(0,h*i,c.px_width,h*(i+1));t.fillStyle="#000";for(let j=0;j<c.x_count;++j){t.fillText(j+1,'+
                    'w*(j+0.5),h*(i+0.5))}}}else{for(let i=0;i<c.x_count;++i){t.fillStyle=e[i%30];t.fillRect(w*i,0,w*(i+1),'+
                    'c.px_height);t.fillStyle="#000";for(let j=0;j<c.y_count;++j){t.fillText(j+1,w*(i+0.5),h*(j+0.5))}}}'+
                    'a.Fb=d.toDataURL();c=document.getElementById("preview-img");if(c)c.src=a.Fb;'
            })
        } else {
            game_js = game_js.replace('Aa(1723));Ve',()=>{
                mod_count++
                return 'Aa(1723));jigidi.image_src=h.image;Ve'
            })
        }
        mod_total++

        /* Enable logging */
        if (debug) {
            game_js = game_js.replace('));d.logToConsole',()=>{
                mod_count++
                return '));true'
            })
            mod_total++
        }

        /* Stack pieces */
        if (GM_config.get('stack_pieces')) {
            game_js = game_js.replace('<a.J',()=>{
                mod_count++
                return '<!a.L*a.J'
            })
            mod_total++
        }

        /* Disable blur when paused */
        if (!GM_config.get('pause_blur')) {
            game_js = game_js.replace('a.C.style.filter= Aa(7692)',()=>{
                mod_count++
                return 'a.C.style.filter=null'
            })
            mod_total++
        }

        /* Enable injection and hide mod */

        /* document.currentScript.indexOf("jigidi") == 12 */
        game_js = game_js.replace('be.d=b?b[a[2]][a[4]](a[3]):0}', ()=>{
            mod_count++
            return 'be.d=12}'
        })
        mod_total++

        /* fake script integrity */
        game_js = game_js.replace('document.currentScript', ()=>{
            mod_count++
            return '{integrity:"'+game_js_sri+'"}'
        })
        mod_total++

        /* ownerDocument.querySelector('script[src*="/game/js"]') */
        game_js = game_js.replace('qd[b[0]][b[1]](b[2])', ()=>{
            mod_count++
            return 'true'
        })
        mod_total++

        return mod_count == mod_total ? game_js : ''
    }

    function modifyCompletionDialog() {
        if (document.getElementById(copy_id))
            return

        const dialog = document.getElementById('completion-message')
        if (!dialog)
            return

        /* Add "Copy" button to completion message dialog */
        const buttons = dialog.getElementsByTagName('button')
        if (!buttons.length)
            return

        const copy = document.createElement('button')
        copy.id = copy_id
        copy.title = 'Copy to clipboard'
        copy.innerHTML = 'Copy'
        copy.style.marginRight = '10px'
        dialog.insertBefore(copy, buttons[0])

        document.addEventListener('click', (event)=>{
            if (event.target.id === copy_id) {
                event.preventDefault(), event.stopPropagation()

                // Clipboard copying requires a user action for security reasons
                const message = document.getElementById('message-content').innerHTML
                const fail = ()=>{
                    alert('Clipboard copying failed!')
                }
                const listener = (event)=>{
                    event.preventDefault()
                    event.clipboardData ? event.clipboardData.setData('text/plain', message) :
                    document.clipboardData ? document.clipboardData.setData('Text', message) : fail()
                }
                document.addEventListener('copy', listener)
                document.execCommand('copy') || fail()
                document.removeEventListener('copy', listener)
            }
        })
    }

    function modifyUI() {
        if (document.getElementById(solve_id))
            return

        /* Add "Solve" button to UI */
        const zoom_in = document.getElementById('game-zoom-in')
        const solve = document.createElement('button')
        solve.id = solve_id
        solve.title = 'Solve'
        solve.className = 'btn'

        /* Old "heart" was a embedded GIF which doesn't scale as smoothly as SVG.
         * Jigidi is using Fontello webfonts for most of symbols, embedded WOFF in CSS.
         * To have same style, this new "heart" is derivated from Fontello Modern Pictograms Heart.
         * SVG is rounded to integers and rewritten by hand to minimize its size.
         */
        solve.style.backgroundImage= "url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-230 -300 1200 1100'>"+
            "<path d='M380 665L95 382q-48-47-72-101-23-50-23-99 0-54 23-95t65-64 96-22q46 0 79 13 29 12 57 37 16 15 51 55l10 12 10-12"+
            "q34-39 51-55 28-25 57-37 34-13 80-13 54 0 96 23t64 64 23 96q0 49-23 99-24 54-72 101z'/></svg>\")"
        zoom_in.parentNode.insertBefore(solve, zoom_in)

        /* Add "Download image" button to UI */
        const image = new Image()
        image.crossOrigin = ''
        image.onload = function() {
            const canvas = document.createElement('canvas')
            canvas.height = image.naturalHeight
            canvas.width = image.naturalWidth
            canvas.getContext('2d').drawImage(image, 0, 0)
            const download_image = document.createElement('a')
            const og_url = document.querySelector('meta[property~="og:url"]')
            const url = og_url ? new URL(og_url.getAttribute('content')) : document.location
            const parts = url.pathname.split('/')
            download_image.download = (parts.length == 5 ? parts[3] : 'jigidi') + '.png'
            download_image.href = canvas.toDataURL()
            download_image.id = randomId()
            download_image.title = 'Download image'
            download_image.className = 'btn'
            download_image.style.backgroundImage= "url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'>"+
                "<path d='M25 19v7H5v-7H0v9c0 1 1 2 3 2h24c2 0 2-1 2-2v-9h-4zM15 18l-6-7s-1 0 0 0h3V9 0h6v10h3v1l-5 7h-1z'/></svg>\")"
            zoom_in.parentNode.insertBefore(download_image, solve)
        }
        image.src = jigidi.image_src

        /* This is UI side of Jigidi solver */
        if (jigidi.solver) {
            jigidi.solver_run = 0
            jigidi.solve = (run)=>{
                const solve = document.getElementById(solve_id).style
                jigidi.solver_run = run
                if (run) {
                    solve.backgroundColor = '#fc5b1f'
                    jigidi.resume()
                    jigidi.solver()
                } else {
                    solve.backgroundColor = ''
                }
            }
            setClickHandler(solve, ()=>{
                jigidi.solve(!jigidi.solver_run)
            })
        } else {
            solve.addEventListener('click', (event)=>{
                const msg = 'Solver is temporarily disabled, try again later'
                event.preventDefault(), event.stopPropagation()
                if (jigidi && jigidi.service && jigidi.service.dialogs) {
                    jigidi.service.dialogs.open('default', { dlg: 'default', txt: msg})
                } else {
                    alert(msg)
                }
            })
        }
    }

    /* Avoid potential reference error */
    if (block_ads)
        window.eval('var googletag = googletag || {}; googletag.cmd = googletag.cmd || [];')

    /* Hide ads */
    if (GM_config.get('hide_ads'))
        GM_addStyle('.ad_unit,.au-base { visibility: hidden }')

    /* As this userscript runs before almost anything we are using MutationObserver to watch for changes being made to the DOM tree.
     * Be careful with MutationObserver! As we are monitoring DOM document and all of its children for additions and removals of elements
     * we are receiving a lot of calls.
     */
    const observer = new MutationObserver((mutations, observer)=>{
        mutations.forEach(({addedNodes})=>{
            addedNodes.forEach(node=>{
                /* We are only interested in <SCRIPT> tags */
                if (node.nodeType === 1 && node.tagName === 'SCRIPT') {
                    /* Inline script does not have location, make it empty */
                    const src = node.src || ''
                    const mimetype = node.type

                    /* Browsers have a fixed list of acceptable MIME types for Javascript.
                     * To block browser from executing the game script we are changing its MIME type.
                     * Just to be sure make the change once and only once. This blocks the script
                     * execution in Safari, Chrome, Edge & IE
                     */
                    if (block_load && /game\/js\/[0-9.]+$/.test(src) && (!mimetype || mimetype !== BLOCKED)) {
                        node.type = BLOCKED
                        node.addEventListener('beforescriptexecute', beforeScriptExecuteListener)

                        /* Remember the exact URL of game script */
                        game_js_src = src
                        game_js_sri = node.integrity
                        game_js_ver = /game\/js\/([0-9.]+)$/.exec(src) || []
                        game_js_ver = game_js_ver.length == 2 ? game_js_ver[1] : 'ersion unknown'

                        /* Remove the node from the DOM */
                        node.parentElement && node.parentElement.removeChild(node)
                    } else if (block_load && !/creator/.test(src) && /js\/release\.js/.test(src) && (!mimetype || mimetype !== BLOCKED)) {
                        node.type = BLOCKED
                        node.addEventListener('beforescriptexecute', beforeScriptExecuteListener)

                        /* Remember the exact URL of game script */
                        game_js_src = src
                        game_js_sri = node.integrity
                        game_js_ver = (new URL(src)).search.replace(/^\?/, '')

                        /* Remove the node from the DOM */
                        node.parentElement && node.parentElement.removeChild(node)
                    } else if (block_ads && (/analytics\.js$/.test(src) || /apstag\.js$/.test(src) || /gpt\.js$/.test(src) || /quant/.test(src)) && (!mimetype || mimetype !== BLOCKED)) {
                        /* Block Amazon Publisher Services, Google Analytics, Google Tag Manager, Quantcast */
                        node.type = BLOCKED
                        node.addEventListener('beforescriptexecute', beforeScriptExecuteListener)

                        /* Remove the node from the DOM */
                        log('Blocking external script ' + src)
                        node.parentElement && node.parentElement.removeChild(node)
                    } else if (block_ads && (/apstag/.test(node.textContent) || /__cd/.test(node.textContent) || /ga\('send'/.test(node.textContent) || /googletag/.test(node.textContent) || /__tcf/.test(node.textContent) || /trace/.test(node.textContent))) {
                        /* Remove 3rd party tracking */
                        node.textContent = ''
                        log('Blocking script')
                    } else if (game_js_src && /new\sJigidi/.test(node.textContent)) {
                        /* For performance reasons only after we have seen and blocked the game script, only then we try to find Jigidi UI script.
                         * Most reliable way is to detect "new Jigidi" construction. Copy the whole JS to variable so that we can inject it later.
                         * Let the browser execute empty script and cleanup resources
                         */
                        let ui_js = node.textContent.replace('jigidi.init()', '')
                        node.textContent = ''
                        node.parentElement && node.parentElement.removeChild(node)

                        /* Now we have blocked the game and UI scripts. Use XHR to get the game script. Browsers hide/ignore this re-request as
                         * it is for same resource in same context; content is in browser cache. No additional latency and onload() is likely
                         * executed synchronously.
                         */
                        GM_xmlhttpRequest({
                            method: 'GET',
                            url: game_js_src,
                            onload: (response)=>{
                                let game_js_src = response.finalUrl, mod_js = '', not_supported = false, show_warning = false

                                const game_js = response.responseText
                                if (!game_js) {
                                    alert('Failed to load script!')
                                    return
                                }

                                /* try patching by version number */
                                switch (game_js_ver)
                                {
                                    case '3.1955':
                                    case '3.1977':
                                    case '14.3.1877':
                                    case '14.3.1977':
                                        mod_js = modifyGame_v3_1955(game_js)
                                        break
                                    case '15.3.2284':
                                        mod_js = modifyGame_v3_2284(game_js)
                                        break
                                    default:
                                        /* try latest version */
                                        mod_js = modifyGame_v3_2284(game_js)
                                        if (mod_js == '')
                                            mod_js = modifyGame_v3_1955(game_js)
                                        show_warning = true
                                        break
                                }

                                if (GM_config.get('copy_button'))
                                    modifyCompletionDialog()

                                /* inject our solver implementation if patching was successful */
                                if (mod_js) {
                                    const msg = 'Jigidi has updated to v' + game_js_ver + '<br>'+
                                          'but solver supports v3.1869 - v3.1977<br>'+
                                          'and v14.3.1877 - v15.3.2284'

                                    addScript(mod_js)
                                    addScript(ui_js)
                                    mod_js = ui_js = ''

                                    if (jigidi && jigidi.load) {
                                        /* override load.begin function to display warnings */
                                        jigidi.load.begin = detour(jigidi.load.begin, ()=>{
                                            if (show_warning)
                                                jigidi.service.dialogs.open('default', { dlg: 'default', txt: msg })
                                        })

                                        /* override load.complete function to patch UI */
                                        jigidi.load.complete = detour(jigidi.load.complete, ()=>{
                                            if (no_message) {
                                                const config = jigidi.config()
                                                if (config && !config.hasMessage)
                                                    jigidi.service.dialogs.open('default', { dlg: 'default', txt: 'Puzzle has no completion message!'})
                                            }
                                            modifyUI()
                                        })
                                    } else if (jigidi && jigidi.loadComplete) {
                                        /* hide "Copy" button until game is complete */
                                        copyButtonDisplay(false)

                                        /* override loadComplete function to display warnings and patch UI */
                                        jigidi.loadComplete = detour(jigidi.loadComplete, ()=>{
                                            if (show_warning) {
                                                setupCompletionMessage(msg)
                                                showCompletionMessage()
                                            } else if (no_message) {
                                                const config = jigidi.config()
                                                if (config && !config.hasMessage) {
                                                    setupCompletionMessage('Puzzle has no completion message!')
                                                    showCompletionMessage()
                                                }
                                            }
                                            modifyUI()
                                        })

                                        jigidi.gameComplete = detour(jigidi.gameComplete, ()=>{
                                            /* show "Copy" button if enabled */
                                            copyButtonDisplay(true)
                                        })
                                    } else {
                                        /* fallback */
                                        alert(msg.replaceAll(/<[^>]*>/g, ' '))
                                    }

                                    /* ..and now start */
                                    window.eval('jigidi && jigidi.init()')
                                } else {
                                    /* reload original game */
                                    const msg = '<b>Automagic Jigidi Solver disabled!</b><br>'+
                                          'Jigidi has updated to v' + game_js_ver + '<br>'+
                                          'but solver supports v3.1869 - v3.1977<br>'+
                                          'and v14.3.1877 - v15.3.2284<br>'+
                                          'Try updating the userscript and then try again.'

                                    /* allow reloading */
                                    block_load = false
                                    let script = loadScript(game_js_src)

                                    /* wait for completion */
                                    script.addEventListener('load', ()=>{
                                        addScript(ui_js)
                                        ui_js = ''

                                        if (jigidi && jigidi.service && jigidi.service.dialogs) {
                                            jigidi.service.dialogs.open('default', { dlg: 'default', txt: msg })
                                        } else if (jigidi) {
                                            /* hide "Copy" button until game is complete */
                                            copyButtonDisplay(false)

                                            jigidi.loadComplete = detour(jigidi.loadComplete, ()=>{
                                                setupCompletionMessage(msg)
                                                showCompletionMessage()
                                            })

                                            jigidi.gameComplete = detour(jigidi.gameComplete, ()=>{
                                                /* show "Copy" button if enabled */
                                                copyButtonDisplay(true)
                                            })
                                        }

                                        /* ..and now start */
                                        window.eval('jigidi && jigidi.init()')
                                    })

                                    /* in case we fail then user knows to temporarily disable the script */
                                    script.addEventListener('error', ()=>{
                                        alert(msg.replaceAll(/<[^>]*>/g, ' '))
                                    })
                                }
                            }
                        })

                        /* inject only once */
                        game_js_src = ''
                    }
                }
            })
        })
    })

    /* start the observer */
    observer.observe(document, { childList: true, subtree: true })

    /* stop the observer when page is loaded */
    window.addEventListener('load', ()=>{ log('Load completed'), observer && observer.disconnect() }, false)

    GM_config.onOpen = function(document, window, frame) {
        frame.style.width = '400px'
        frame.style.height = '450px'
        this.center()
    }

    GM_config.onSave = function() {
        if (!confirm('Page will be reloaded, do you want to continue?'))
            return
        location.reload()
    }

    GM_registerMenuCommand('Configure', GM_config.open.bind(GM_config), 'C')
})();