Codenames X-Ray

Reveal hidden Codenames card colors using game WebSocket data.

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

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

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

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

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

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

(I already have a user script manager, let me install it!)

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.

(I already have a user style manager, let me install it!)

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