Codenames X-Ray

Reveal hidden Codenames card colors using game WebSocket data.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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