Codenames X-Ray

Reveal hidden Codenames card colors using game WebSocket data.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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