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();
    }
})();