☰

YUA × XToys Bridge

Connect YUA's games with XToys.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         YUA × XToys Bridge
// @name:zh-CN   YUA × XToys Bridge
// @name:ja      YUA × XToys Bridge
// @name:en      YUA × XToys Bridge
// @namespace    yua-xtoys
// @version      1.0.0
// @description         Connect YUA's games with XToys.
// @description:zh-CN   将 YUA 的游戏事件实时联动至 XToys 设备。
// @description:ja      YUA様のゲーム内イベントをXToysデバイスとリアルタイムで連携します。
// @description:en      Connect YUA's in-game events to XToys devices in real time.
// @author       Aoba
// @match        https://yotogi-chan.itch.io/*
// @match        https://html-classic.itch.zone/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    const VERSION = '1.0.0';
    const OUTER_ORIGIN = 'https://yotogi-chan.itch.io';
    const GAME_ORIGIN = 'https://html-classic.itch.zone';
    const STORAGE_KEY = 'yua.manager.v1';
    const LANG_KEY = 'yua.xtoys.lang';

    // Prevent duplicate initialization
    if (window.__YUA_XTOYS_V1__) return;
    window.__YUA_XTOYS_V1__ = true;

    const log = (...args) => console.log(
        `%c[YUA × XToys ${VERSION}]`,
        'background:#8e24aa;color:white;font-weight:bold;padding:2px 5px',
        ...args
    );

    /* =========================================================
       i18n
       ========================================================= */

    const I18N = {
        'zh-CN': {
            bridgeReady: 'Bridge 已就绪',
            waitingGame: '等待游戏',
            gameReady: '游戏检测已就绪',
            lastEvent: '最后事件',
            none: '—',
            pulse: 'Rapid Tap',
            punishment: '处罚',
            sec: '秒',

            temptation: '拒绝 YUA 的诱惑',
            false_cheat: '错误揭发作弊',
            tongue_self: '舌尖裁定:选择自己',
            othello_loss: '黑白棋失败',
            mahjong_loss: '麻将失败',

            othello_win: '黑白棋胜利',
            mahjong_win: '麻将胜利',
            temptation_follow: '服从 YUA 的诱惑',
            tongue_follow: '舌尖裁定:交给 YUA'
        },

        ja: {
            bridgeReady: 'Bridge 準備完了',
            waitingGame: 'ゲームを待機中',
            gameReady: 'ゲーム検出 準備完了',
            lastEvent: '最後のイベント',
            none: '—',
            pulse: 'Rapid Tap',
            punishment: '罰',
            sec: '秒',

            temptation: 'YUA様の誘惑を拒否',
            false_cheat: '誤って作弊を指摘',
            tongue_self: '舌尖裁定:自分で打つ',
            othello_loss: 'オセロ敗北',
            mahjong_loss: '麻雀敗北',

            othello_win: 'オセロ勝利',
            mahjong_win: '麻雀勝利',
            temptation_follow: 'YUA様の誘惑に従う',
            tongue_follow: '舌尖裁定:YUA様に任せる'
        },

        en: {
            bridgeReady: 'Bridge Ready',
            waitingGame: 'Waiting for Game',
            gameReady: 'Game Detector Ready',
            lastEvent: 'Last Event',
            none: '—',
            pulse: 'Rapid Tap',
            punishment: 'Punishment',
            sec: 'sec',

            temptation: "YUA's temptation refused",
            false_cheat: 'False cheat accusation',
            tongue_self: 'Tongue Judgement: self choice',
            othello_loss: 'Othello loss',
            mahjong_loss: 'Mahjong loss',

            othello_win: 'Othello win',
            mahjong_win: 'Mahjong win',
            temptation_follow: "Followed YUA's temptation",
            tongue_follow: 'Tongue Judgement: followed YUA'
        }
    };

    function detectLanguage() {
        const saved = localStorage.getItem(LANG_KEY);

        if (I18N[saved]) {
            return saved;
        }

        const browser =
            (navigator.language || '').toLowerCase();

        if (browser.startsWith('ja')) {
            return 'ja';
        }

        if (browser.startsWith('zh')) {
            return 'zh-CN';
        }

        return 'en';
    }

    /* =========================================================
       OUTER PAGE
       itch.io → XToys Webpage Monitor
       ========================================================= */

    if (location.origin === OUTER_ORIGIN) {

        let lang = detectLanguage();

        let lastState = {
            type: 'none'
        };

        let gameReady = false;

        /* -----------------------------------------------------
           Hidden XToys bridge
           ----------------------------------------------------- */

        let bridge =
            document.querySelector('#yua-xtoys-bridge');

        if (!bridge) {
            bridge = document.createElement('div');

            bridge.id = 'yua-xtoys-bridge';
            bridge.style.display = 'none';
            bridge.textContent = 'ready';

            document.body.appendChild(bridge);
        }

        /* -----------------------------------------------------
           Status UI
           ----------------------------------------------------- */

        let status =
            document.querySelector('#yua-xtoys-status');

        if (!status) {
            status = document.createElement('div');
            status.id = 'yua-xtoys-status';

            Object.assign(
                status.style,
                {
                    position: 'fixed',
                    right: '12px',
                    bottom: '12px',
                    zIndex: '2147483647',

                    minWidth: '230px',

                    padding: '10px 12px',

                    background:
                        'rgba(20,12,24,.90)',

                    color: '#eee',

                    border:
                        '1px solid rgba(210,120,255,.45)',

                    borderRadius: '9px',

                    font:
                        '12px/1.45 sans-serif',

                    boxShadow:
                        '0 2px 12px rgba(0,0,0,.35)',

                    backdropFilter:
                        'blur(5px)'
                }
            );

            document.body.appendChild(status);
        }

        function eventLabel(reason) {
            return (
                I18N[lang][reason]
                ||
                reason
                ||
                I18N[lang].none
            );
        }

        function render() {

            const t = I18N[lang];

            let eventText = t.none;
            let detail = '';

            if (lastState.type === 'pulse') {

                eventText =
                    t.pulse;

            } else if (
                lastState.type === 'punish'
            ) {

                eventText =
                    eventLabel(
                        lastState.reason
                    );

                detail =
                    `${t.punishment}: ` +
                    `${lastState.seconds} ${t.sec}`;

            } else if (
                lastState.type === 'status'
            ) {

                eventText =
                    eventLabel(
                        lastState.key
                    );
            }

            status.innerHTML = `
                <div style="
                    display:flex;
                    align-items:center;
                    justify-content:space-between;
                    gap:12px;
                ">
                    <strong style="
                        color:#e69cff;
                    ">
                        YUA × XToys
                    </strong>

                    <select
                        id="yua-xtoys-lang"
                        style="
                            font:11px sans-serif;
                            background:#211526;
                            color:#eee;
                            border:1px solid #704080;
                            border-radius:5px;
                            padding:2px 4px;
                        "
                    >
                        <option
                            value="zh-CN"
                            ${lang === 'zh-CN'
                                ? 'selected'
                                : ''}
                        >
                            中文
                        </option>

                        <option
                            value="ja"
                            ${lang === 'ja'
                                ? 'selected'
                                : ''}
                        >
                            日本語
                        </option>

                        <option
                            value="en"
                            ${lang === 'en'
                                ? 'selected'
                                : ''}
                        >
                            English
                        </option>
                    </select>
                </div>

                <div style="
                    border-top:
                        1px solid rgba(255,255,255,.12);
                    margin:6px 0;
                "></div>

                <div>
                    ● ${t.bridgeReady}
                </div>

                <div>
                    ${
                        gameReady
                        ? '● ' + t.gameReady
                        : '○ ' + t.waitingGame
                    }
                </div>

                <div style="
                    margin-top:7px;
                    opacity:.7;
                ">
                    ${t.lastEvent}
                </div>

                <div>
                    ${eventText}
                </div>

                ${
                    detail
                    ? `
                        <div style="
                            color:#e69cff;
                        ">
                            ${detail}
                        </div>
                    `
                    : ''
                }

                <div style="
                    text-align:right;
                    opacity:.45;
                    margin-top:4px;
                ">
                    v${VERSION}
                </div>
            `;

            const selector =
                status.querySelector(
                    '#yua-xtoys-lang'
                );

            selector.addEventListener(
                'change',

                e => {

                    lang =
                        e.target.value;

                    localStorage.setItem(
                        LANG_KEY,
                        lang
                    );

                    render();
                }
            );
        }

        render();

        /* -----------------------------------------------------
           Game → outer page receiver
           ----------------------------------------------------- */

        window.addEventListener(
            'message',

            e => {

                if (
                    e.origin !== GAME_ORIGIN
                ) {
                    return;
                }

                const d =
                    e.data;

                if (
                    !d
                    ||
                    d.source !== 'YUA_XTOYS'
                ) {
                    return;
                }

                /* ---------- READY ---------- */

                if (
                    d.type === 'ready'
                ) {

                    gameReady = true;

                    render();

                    log(
                        'Game Detector connected',
                        d.version
                    );

                    return;
                }

                /* ---------- STATUS ---------- */

                if (
                    d.type === 'status'
                ) {

                    lastState = {
                        type: 'status',
                        key: d.key
                    };

                    render();

                    return;
                }

                /* ---------- PULSE ---------- */

                if (
                    d.type === 'pulse'
                ) {

                    bridge.textContent =
                        `pulse:${d.seq}`;

                    gameReady = true;

                    lastState = {
                        type: 'pulse',
                        reason: d.reason
                    };

                    render();

                    log(
                        'PULSE ← GAME',
                        d.reason || '',
                        d.seq
                    );

                    return;
                }

                /* ---------- PUNISH ---------- */

                if (
                    d.type === 'punish'
                ) {

                    const seconds =
                        Number(
                            d.seconds
                        );

                    if (
                        !Number.isFinite(seconds)
                        ||
                        seconds <= 0
                    ) {
                        return;
                    }

                    /*
                     * Keep protocol reasons language-independent.
                     */

                    const reason =
                        String(
                            d.reason || 'unknown'
                        )
                            .replace(
                                /[^a-z0-9_]/gi,
                                '_'
                            )
                            .toLowerCase();

                    bridge.textContent =
                        `punish:${seconds}:${reason}:${d.seq}`;

                    gameReady = true;

                    lastState = {
                        type: 'punish',
                        seconds,
                        reason
                    };

                    render();

                    log(
                        'PUNISH ← GAME',
                        `${seconds}s`,
                        reason,
                        d.seq
                    );
                }
            }
        );

        log(
            'Outer Bridge READY'
        );

        return;
    }

    /* =========================================================
       GAME IFRAME
       ========================================================= */

    if (
        location.origin !== GAME_ORIGIN
    ) {
        return;
    }

    /* =========================================================
       Sequence
       ========================================================= */

    let seq =
        Date.now();

    function nextSeq() {

        const now =
            Date.now();

        seq =
            Math.max(
                seq + 1,
                now
            );

        return seq;
    }

    /* =========================================================
       Messaging
       ========================================================= */

    function sendMessage(data) {

        parent.postMessage(
            {
                source: 'YUA_XTOYS',
                ...data
            },

            OUTER_ORIGIN
        );
    }

    function sendStatus(key) {

        sendMessage({
            type: 'status',
            key
        });
    }

    /* =========================================================
       XToys output
       ========================================================= */

    function pulse(
        reason = 'pulse'
    ) {

        const id =
            nextSeq();

        sendMessage({
            type: 'pulse',
            reason,
            seq: id
        });

        log(
            'PULSE → XToys',
            reason,
            id
        );
    }

    function punish(
        seconds,
        reason = 'unknown'
    ) {

        seconds =
            Number(seconds);

        if (
            !Number.isFinite(seconds)
            ||
            seconds <= 0
        ) {
            return;
        }

        const id =
            nextSeq();

        sendMessage({
            type: 'punish',
            seconds,
            reason,
            seq: id
        });

        log(
            'PUNISH → XToys',
            `${seconds}s`,
            reason,
            id
        );
    }

    /*
     * Debug interface.
     */

    window.YUA_XTOYS = {
        pulse,
        punish,
        version: VERSION
    };

    /* =========================================================
       Game state
       ========================================================= */

    function getState() {

        try {

            return JSON.parse(
                localStorage.getItem(
                    STORAGE_KEY
                )
                ||
                '{}'
            );

        } catch {

            return {};
        }
    }

    const initialState =
        getState();

    /*
     * Remember the event that already existed
     * when the script started.
     *
     * This prevents a refresh from replaying
     * the previous punishment.
     */

    let lastEventId =
        initialState.events
            ?.at(-1)
            ?.id
        ||
        null;

    /* =========================================================
       OTHELLO / MAHJONG
       ========================================================= */

    function handleGameEvent(event) {

        if (!event) {
            return;
        }

        log(
            'GAME EVENT',
            event.type,
            event
        );

        /* -----------------------------------------------------
           OTHELLO WIN
           ----------------------------------------------------- */

        if (
            event.type === 'othello.win'
        ) {

            log(
                'Othello WIN → no punishment'
            );

            sendStatus(
                'othello_win'
            );

            return;
        }

        /* -----------------------------------------------------
           OTHELLO LOSE
           ----------------------------------------------------- */

        if (
            event.type === 'othello.lose'
        ) {

            const lost =
                Number(
                    event.detail
                        ?.losingSquares
                    ||
                    0
                );

            if (
                lost > 0
            ) {

                log(
                    `Othello LOSE → ${lost}s`,
                    event.detail
                );

                punish(
                    lost,
                    'othello_loss'
                );
            }

            return;
        }

        /* -----------------------------------------------------
           MAHJONG WIN
           ----------------------------------------------------- */

        if (
            event.type === 'mahjong.win'
        ) {

            log(
                'Mahjong WIN → no punishment'
            );

            sendStatus(
                'mahjong_win'
            );

            return;
        }

        /* -----------------------------------------------------
           MAHJONG LOSE
           ----------------------------------------------------- */

        if (
            event.type === 'mahjong.lose'
        ) {

            const points =
                Number(
                    event.detail
                        ?.points
                    ||
                    0
                );

            /*
             * 1000 points = 1 second
             * Minimum 1 second
             * Maximum 9.6 seconds
             */

            const seconds =
                Math.min(
                    9.6,

                    Math.max(
                        1,
                        points / 1000
                    )
                );

            log(
                `Mahjong LOSE ${points}pt → ${seconds}s`,
                event.detail
            );

            punish(
                seconds,
                'mahjong_loss'
            );

            return;
        }
    }

    /* ---------------------------------------------------------
       Match event watcher
       --------------------------------------------------------- */

    setInterval(
        () => {

            const state =
                getState();

            const latest =
                state.events
                    ?.at(-1);

            if (!latest) {
                return;
            }

            const id =
                latest.id
                ||
                JSON.stringify(
                    latest
                );

            if (
                id === lastEventId
            ) {
                return;
            }

            lastEventId =
                id;

            handleGameEvent(
                latest
            );
        },

        200
    );

    /* =========================================================
       RAPID TAP
       ========================================================= */

    document.addEventListener(
        'pointerdown',

        e => {

            const button =
                e.target.closest?.(
                    '#tapTemptationBtn'
                );

            if (!button) {
                return;
            }

            pulse(
                'rapid_tap'
            );
        },

        true
    );

    /* =========================================================
       TONGUE JUDGEMENT
       ========================================================= */

    document.addEventListener(
        'pointerdown',

        e => {

            /*
             * Follow YUA
             */

            const follow =
                e.target.closest?.(
                    '#tongueJudgementFollowBtn'
                );

            if (follow) {

                log(
                    'Tongue → Follow YUA → no punishment'
                );

                sendStatus(
                    'tongue_follow'
                );

                return;
            }

            /*
             * Choose self
             */

            const refuse =
                e.target.closest?.(
                    '#tongueJudgementRefuseBtn'
                );

            if (refuse) {

                log(
                    'Tongue → Self choice → 1s'
                );

                punish(
                    1,
                    'tongue_self'
                );

                return;
            }
        },

        true
    );

    /* =========================================================
       NORMAL TEMPTATION

       Verified final method:
       capture-phase pointerdown

       Legal cell:
       .cell.legal

       YUA target:
       .cell.legal.tempted
       ========================================================= */

    document.addEventListener(
        'pointerdown',

        e => {

            /*
             * These events are handled separately.
             */

            if (
                e.target.closest?.(
                    '#tongueJudgementFollowBtn'
                )
                ||
                e.target.closest?.(
                    '#tongueJudgementRefuseBtn'
                )
                ||
                e.target.closest?.(
                    '#tapTemptationBtn'
                )
            ) {
                return;
            }

            /*
             * Find the clicked board cell from
             * the actual event path.
             */

            const path =
                e.composedPath?.()
                ||
                [];

            const clicked =
                path.find(
                    el =>
                        el
                            ?.classList
                            ?.contains?.(
                                'cell'
                            )
                );

            if (!clicked) {
                return;
            }

            /*
             * Only legal moves matter.
             */

            if (
                !clicked
                    .classList
                    .contains(
                        'legal'
                    )
            ) {
                return;
            }

            /*
             * Read YUA's target at the exact
             * pointerdown moment.
             */

            const tempted =
                document.querySelector(
                    '.cell.legal.tempted'
                );

            /*
             * No temptation event active.
             */

            if (!tempted) {
                return;
            }

            const clickedIndex =
                clicked.dataset.index;

            const temptedIndex =
                tempted.dataset.index;

            if (
                clickedIndex == null
                ||
                temptedIndex == null
            ) {
                return;
            }

            log(
                'Temptation move',
                {
                    clickedIndex,
                    temptedIndex
                }
            );

            /* -------------------------------------------------
               Follow YUA
               ------------------------------------------------- */

            if (
                clickedIndex ===
                temptedIndex
            ) {

                log(
                    'Temptation → Follow YUA → no punishment'
                );

                sendStatus(
                    'temptation_follow'
                );

                return;
            }

            /* -------------------------------------------------
               Refuse YUA
               ------------------------------------------------- */

            log(
                'Temptation → Refused YUA → 1s'
            );

            punish(
                1,
                'temptation'
            );
        },

        true
    );

    /* =========================================================
       CHEAT CHALLENGE
       ========================================================= */

    let previousCheatFalse =
        false;

    function detectCheatResult(
        text
    ) {

        const falseCheat =
            text.includes(
                '误判惩罚:'
            )
            ||
            text.includes(
                '猜错♡'
            );

        /*
         * Trigger only on false → true.
         */

        if (
            falseCheat
            &&
            !previousCheatFalse
        ) {

            log(
                'False Cheat accusation → 1s'
            );

            punish(
                1,
                'false_cheat'
            );
        }

        previousCheatFalse =
            falseCheat;
    }

    let cheatScanScheduled =
        false;

    function scanCheatDOM() {

        cheatScanScheduled =
            false;

        const text =
            document.body
                ?.innerText
            ||
            '';

        detectCheatResult(
            text
        );
    }

    const cheatObserver =
        new MutationObserver(
            () => {

                if (
                    cheatScanScheduled
                ) {
                    return;
                }

                cheatScanScheduled =
                    true;

                requestAnimationFrame(
                    scanCheatDOM
                );
            }
        );

    cheatObserver.observe(
        document.body,
        {
            subtree: true,
            childList: true,
            characterData: true
        }
    );

    /* =========================================================
       READY
       ========================================================= */

    log(
        'Game Detector READY'
    );

    log(
        'Enabled:',
        'Othello / Mahjong / Temptation / Cheat / Rapid Tap / Tongue'
    );

    sendMessage({
        type: 'ready',
        version: VERSION
    });

})();