Codenames X-Ray

Reveal hidden Codenames card colors using game WebSocket data.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Codenames X-Ray
// @namespace    https://github.com/im-yellow
// @version      1.0.1
// @description  Reveal hidden Codenames card colors using game WebSocket data.
// @author       Y-ellow (Discord: @y.lw)
// @match        https://codenames.game/*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const OriginalWebSocket = window.WebSocket;
    const PREFIX = 'codenames_colors_';
    const COLORS = ['blue', 'red', 'neutral', 'black'];
    const TEAMS = ['red', 'blue'];
    const ROLES = ['operatives', 'spymasters'];

    const state = {
        roomId: null,
        playerId: null,
        credentials: null,
        stateId: null,

        team: null,
        role: null,

        rememberedTeam: null,
        rememberedRole: null,

        gameCards: [],
        socket: null,

        showColors: false,
        lockSent: new Set()
    };

    window.codenames = state;

    function storageKey(roomId) {
        return PREFIX + roomId;
    }

    function validColor(color) {
        return COLORS.includes(color);
    }

    function savedGame(roomId = state.roomId) {
        if (!roomId) return null;

        try {
            const data = JSON.parse(
                localStorage.getItem(storageKey(roomId))
            );

            if (
                !data ||
                !Array.isArray(data.cards) ||
                data.cards.length !== 25
            ) {
                return null;
            }

            if (data.cards.some(card =>
                !card ||
                typeof card.word !== 'string' ||
                !validColor(card.color)
            )) {
                return null;
            }

            return data;
        } catch {
            return null;
        }
    }

    function saveGame() {
        if (!state.roomId || state.gameCards.length < 25) {
            return false;
        }

        const cards = [];

        for (let i = 0; i < 25; i++) {
            const card = state.gameCards[i];

            if (
                !card ||
                typeof card.word !== 'string' ||
                !validColor(card.color)
            ) {
                return false;
            }

            cards.push({
                word: card.word,
                color: card.color
            });
        }

        localStorage.setItem(
            storageKey(state.roomId),
            JSON.stringify({
                roomId: state.roomId,
                cards
            })
        );

        return true;
    }

    function loadGame(roomId) {
        const saved = savedGame(roomId);

        if (!saved) return false;

        state.gameCards = saved.cards.map((card, id) => ({
            id,
            word: card.word,
            color: card.color,
            revealed: false
        }));

        return true;
    }

    function deleteGame(roomId) {
        if (roomId) {
            localStorage.removeItem(storageKey(roomId));
        }
    }

    function setRoom(roomId) {
        if (!roomId) return;

        roomId = String(roomId);

        if (state.roomId === roomId) return;

        state.roomId = roomId;
        state.stateId = null;
        state.gameCards = [];
        state.showColors = false;

        state.rememberedTeam = null;
        state.rememberedRole = null;

        loadGame(roomId);
        updateButton();
    }

    function resetGame() {
        deleteGame(state.roomId);

        state.roomId = null;
        state.stateId = null;
        state.team = null;
        state.role = null;

        state.rememberedTeam = null;
        state.rememberedRole = null;

        state.gameCards = [];
        state.showColors = false;

        updateButton();
    }

    function card(id) {
        if (id < 0 || id > 24) return null;

        if (!state.gameCards[id]) {
            state.gameCards[id] = {
                id,
                word: null,
                color: null,
                revealed: false
            };
        }

        return state.gameCards[id];
    }

    function updateCard(id, data) {
        const target = card(id);

        if (!target || !data) return;

        if (data.word !== undefined) {
            target.word = data.word;
        }

        if (data.color !== undefined) {
            target.color = data.color;
        }

        if (data.revealed !== undefined) {
            target.revealed = data.revealed;
        }
    }

    function updateGrid(grid) {
        if (!Array.isArray(grid)) return;

        for (const item of grid) {
            if (
                !item ||
                item.index === undefined ||
                item.index < 0 ||
                item.index > 24
            ) {
                continue;
            }

            updateCard(item.index, {
                word: item.word,
                color: item.color,
                revealed: item.revealed
            });
        }
    }

    function updateTeam(teams) {
        if (
            state.playerId === null ||
            state.playerId === undefined ||
            !teams
        ) {
            state.team = null;
            state.role = null;
            return;
        }

        const player = `p#${state.playerId}`;

        for (const team of TEAMS) {
            for (const role of ROLES) {
                if (teams[team]?.[role]?.[player]) {
                    state.team = team;
                    state.role = role;
                    return;
                }
            }
        }

        state.team = null;
        state.role = null;
    }

    function rememberTeam() {
        state.rememberedTeam = state.team;
        state.rememberedRole = state.role;
    }

    function hasAllWords() {
        if (state.gameCards.length < 25) return false;

        return state.gameCards.every(card =>
            card &&
            typeof card.word === 'string'
        );
    }

    function captureColors(operations) {
        let changed = false;

        for (const op of operations) {
            const match = op?.path?.match(
                /^\/G\/grid\/(\d+)\/color$/
            );

            if (!match) continue;

            const id = Number(match[1]);

            if (
                id >= 0 &&
                id <= 24 &&
                validColor(op.value)
            ) {
                updateCard(id, {
                    color: op.value
                });

                changed = true;
            }
        }

        if (!changed) return false;

        if (!hasAllWords()) return false;

        for (let i = 0; i < 25; i++) {
            if (!validColor(state.gameCards[i]?.color)) {
                return false;
            }
        }

        return saveGame();
    }

    function send(packet) {
        if (!state.socket) return false;

        try {
            state.socket.send(
                '42/cno2,' + JSON.stringify(packet)
            );

            return true;
        } catch {
            return false;
        }
    }

    function move(type, args) {
        const playerID = String(state.playerId);

        return [
            'update',
            {
                type: 'MAKE_MOVE',
                payload: {
                    type,
                    args,
                    playerID,
                    credentials: state.credentials
                }
            },
            state.stateId,
            state.roomId,
            playerID
        ];
    }

    function sendUnlockSequence() {
        if (
            !state.socket ||
            !state.roomId ||
            state.playerId === null ||
            state.playerId === undefined ||
            !state.credentials ||
            state.stateId === null ||
            state.stateId === undefined ||
            savedGame() ||
            state.lockSent.has(state.roomId) ||
            !hasAllWords()
        ) {
            return;
        }

        rememberTeam();

        const team = state.rememberedTeam;
        const role = state.rememberedRole;

        send(
            move('setLockTeams', [false])
        );

        state.lockSent.add(state.roomId);

        send(
            move('joinTeam', [
                'red',
                'spymasters'
            ])
        );

        if (
            (team === 'red' || team === 'blue') &&
            (role === 'operatives' || role === 'spymasters')
        ) {
            send(
                move('joinTeam', [
                    team,
                    role
                ])
            );
        } else {
            send(
                move('leaveTeam', [
                    String(state.playerId)
                ])
            );
        }
    }

    function processSync(message) {
        const roomId = message[1];
        const data = message[2];

        if (roomId) {
            setRoom(roomId);
        }

        if (!data?.state) return;

        const game = data.state;

        if (game._stateID !== undefined) {
            state.stateId = game._stateID;
        }

        if (game.G) {
            updateTeam(game.G.teams);
            updateGrid(game.G.grid);
        }

        sendUnlockSequence();
    }

    function processPatch(message) {
        const roomId = message[1];
        const stateId = message[3];
        const operations = message[4];

        if (roomId) {
            setRoom(roomId);
        }

        if (
            state.roomId &&
            String(roomId) !== String(state.roomId)
        ) {
            return;
        }

        if (stateId !== undefined) {
            state.stateId = stateId;
        }

        if (!Array.isArray(operations)) return;

        for (const op of operations) {
            if (!op?.path) continue;

            if (op.path === '/_stateID') {
                state.stateId = op.value;
                continue;
            }

            if (op.path === '/G/teams') {
                if (op.op === 'replace') {
                    updateTeam(op.value);
                }
                continue;
            }

            const teamMatch = op.path.match(
                /^\/G\/teams\/(red|blue)\/(operatives|spymasters)\/(.+)$/
            );

            if (teamMatch) {
                continue;
            }

            let match = op.path.match(
                /^\/G\/grid\/(\d+)$/
            );

            if (match && op.value) {
                updateCard(Number(match[1]), {
                    word: op.value.word,
                    color: op.value.color,
                    revealed: op.value.revealed
                });
                continue;
            }

            match = op.path.match(
                /^\/G\/grid\/(\d+)\/color$/
            );

            if (match) {
                updateCard(Number(match[1]), {
                    color: op.value
                });
                continue;
            }

            match = op.path.match(
                /^\/G\/grid\/(\d+)\/revealed$/
            );

            if (match) {
                updateCard(Number(match[1]), {
                    revealed: op.value
                });
            }
        }

        const saved = captureColors(operations);

        if (
            saved ||
            (!savedGame() && hasAllWords())
        ) {
            sendUnlockSequence();
        }

        if (state.showColors) {
            applySavedColors();
        }
    }

    function processMatchData(message) {
        const roomId = message[1];

        if (roomId) {
            setRoom(roomId);
        }

        sendUnlockSequence();
    }

    function processPlayNextMatch(message) {
        resetGame();

        if (message[1]) {
            setRoom(message[1]);
        }

        sendUnlockSequence();
    }

    function processIncoming(data) {
        if (
            typeof data !== 'string' ||
            !data.startsWith('42/cno2,')
        ) {
            return;
        }

        try {
            const message = JSON.parse(data.slice(8));

            if (!Array.isArray(message)) return;

            switch (message[0]) {
                case 'sync':
                    processSync(message);
                    break;

                case 'patch':
                    processPatch(message);
                    break;

                case 'matchData':
                    processMatchData(message);
                    break;

                case 'playNextMatch':
                    processPlayNextMatch(message);
                    break;
            }
        } catch {}
    }

    function processOutgoing(data) {
        if (
            typeof data !== 'string' ||
            !data.startsWith('42/cno2,')
        ) {
            return;
        }

        try {
            const message = JSON.parse(data.slice(8));

            if (!Array.isArray(message)) return;

            if (message[0] === 'sync') {
                if (message[1]) {
                    setRoom(message[1]);
                }

                if (
                    message[2] !== undefined &&
                    message[2] !== null
                ) {
                    state.playerId = String(message[2]);
                }

                if (message[3]) {
                    state.credentials = message[3];
                }

                return;
            }

            if (message[0] !== 'update') return;

            const payload = message[1]?.payload;

            if (!payload) return;

            if (
                payload.playerID !== undefined &&
                payload.playerID !== null
            ) {
                state.playerId = String(
                    payload.playerID
                );
            }

            if (payload.type === 'joinTeam') {
                const [team, role] = payload.args || [];

                if (
                    TEAMS.includes(team) &&
                    ROLES.includes(role)
                ) {
                    state.team = team;
                    state.role = role;
                }

                return;
            }

            if (payload.type === 'leaveTeam') {
                state.team = null;
                state.role = null;
            }
        } catch {}
    }

    function getCards() {
        return [
            ...document.querySelectorAll(
                'article[style*="--CardColor"]'
            )
        ].slice(0, 25);
    }

    function setCardColor(id, color) {
        if (
            id < 0 ||
            id > 24 ||
            !validColor(color)
        ) {
            return;
        }

        const card = getCards()[id];

        if (!card) return;

        card.style.setProperty(
            '--CardColor',
            `var(--${color}-cardBg)`
        );

        card.style.setProperty(
            '--CardBorder',
            `var(--${color}-cardBorder)`
        );

        card.style.setProperty(
            '--InnerBorder',
            `var(--${color}-innerBorder)`
        );

        const inner = card.querySelector(
            'section.mx-auto'
        );

        if (inner) {
            inner.style.background =
                `var(--${color}-innerBg)`;
        }

        const word = card.querySelector(
            'article.font-barlowCondensed'
        );

        if (word) {
            word.style.backgroundColor =
                `var(--${color}-darkWordBg)`;
        }
    }

    function applySavedColors() {
        const saved = savedGame();

        if (!saved) return false;

        const cards = getCards();

        if (cards.length < 25) return false;

        for (let i = 0; i < 25; i++) {
            setCardColor(
                i,
                saved.cards[i].color
            );
        }

        return true;
    }

    function hideColors() {
        const cards = getCards();

        if (cards.length < 25) return;

        for (let i = 0; i < 25; i++) {
            if (
                !state.gameCards[i] ||
                !state.gameCards[i].revealed
            ) {
                setCardColor(i, 'neutral');
            }
        }
    }

    function toggleColors() {
        if (!savedGame()) {
            alert(
                'No saved colors for this game. Try refreshing the page.'
            );
            return;
        }

        state.showColors = !state.showColors;

        if (state.showColors) {
            applySavedColors();
        } else {
            hideColors();
        }

        updateButton();
    }

    function updateButton() {
        const button = document.getElementById(
            'codenames-show-colors'
        );

        if (button) {
            button.textContent =
                state.showColors
                    ? 'Hide Colors'
                    : 'Show Colors';
        }
    }

    function createButton() {
        if (
            document.getElementById(
                'codenames-show-colors'
            )
        ) {
            return;
        }

        const news = [
            ...document.querySelectorAll('button')
        ].find(
            button =>
                button.textContent.trim() === 'News'
        );

        if (!news?.parentElement) return;

        const button = document.createElement('button');

        button.id = 'codenames-show-colors';
        button.type = 'button';
        button.className = news.className;
        button.textContent = 'Show Colors';

        const stop = event => {
            event.preventDefault();
            event.stopPropagation();
        };

        button.addEventListener('click', event => {
            stop(event);
            toggleColors();
        });

        button.addEventListener('mousedown', stop);
        button.addEventListener('pointerdown', stop);

        news.parentElement.insertBefore(
            button,
            news
        );

        updateButton();
    }

    function startUI() {
        createButton();

        const observer = new MutationObserver(() => {
            createButton();

            if (state.showColors) {
                applySavedColors();
            }
        });

        observer.observe(
            document.documentElement,
            {
                childList: true,
                subtree: true
            }
        );
    }

    function hookWebSocket(ws) {
        state.socket = ws;

        ws.addEventListener(
            'message',
            event => processIncoming(event.data)
        );

        const originalSend = ws.send.bind(ws);

        ws.send = function (data) {
            processOutgoing(data);
            return originalSend(data);
        };
    }

    class CodenamesWebSocket
        extends OriginalWebSocket {
        constructor(...args) {
            super(...args);
            hookWebSocket(this);
        }
    }

    window.WebSocket = CodenamesWebSocket;

    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            startUI,
            { once: true }
        );
    } else {
        startUI();
    }
})();