TTRPG Pairwise Ranking Tool

🧩 Syntax:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pairwise Ranking Tool</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Inter', sans-serif;
        }
        .choice-btn {
            transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
        }
        .choice-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
        }
        .choice-btn:active {
            transform: translateY(0);
            box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
        }
        #results-container {
            max-height: 60vh;
            overflow-y: auto;
        }
    </style>
</head>
<body class="bg-gray-50 text-gray-800 flex items-center justify-center min-h-screen p-4">
    <div class="w-full max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6 sm:p-8">

        <div id="setup-screen">
            <h1 class="text-3xl font-bold text-center text-gray-900 mb-2">Pairwise Ranking Setup</h1>
            <p class="text-center text-gray-600 mb-6">Enter categories and their options below.</p>
            <textarea id="item-input" class="w-full h-48 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" placeholder="Category One&#10;Option A&#10;Option B&#10;-&#10;Category Two&#10;Option C&#10;Option D&#10;-"></textarea>
            <div class="mt-6 flex flex-col sm:flex-row sm:justify-center gap-3">
                <button id="start-button" class="w-full sm:w-auto bg-indigo-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200">Start Ranking</button>
                <button id="load-ttrpg-button" class="w-full sm:w-auto bg-gray-200 text-gray-700 font-semibold py-3 px-6 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400 transition-all duration-200">Load TTRPG Preferences</button>
            </div>
        </div>

        <div id="ranking-screen" class="hidden">
            <h1 class="text-3xl font-bold text-center text-gray-900 mb-2">Which is better?</h1>
            <p id="progress-text" class="text-center text-gray-500 mb-8">Comparison 1 of X</p>
            <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                <button id="choice-a" class="choice-btn w-full h-48 sm:h-64 bg-white border-2 border-gray-200 rounded-lg p-4 flex flex-col items-center justify-center text-center hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500">
                    <span id="choice-a-option" class="block font-semibold"></span>
                    <span id="choice-a-category" class="block text-sm text-gray-500 mt-2"></span>
                </button>
                <button id="choice-b" class="choice-btn w-full h-48 sm:h-64 bg-white border-2 border-gray-200 rounded-lg p-4 flex flex-col items-center justify-center text-center hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500">
                    <span id="choice-b-option" class="block font-semibold"></span>
                    <span id="choice-b-category" class="block text-sm text-gray-500 mt-2"></span>
                </button>
            </div>
             <div class="flex justify-between items-center mt-6">
                <button id="undo-button" class="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">Undo Last Choice</button>
                <button id="show-results-button" class="bg-green-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200">Finish & See Results</button>
            </div>
        </div>

        <div id="results-screen" class="hidden">
            <h1 class="text-3xl font-bold text-center text-gray-900 mb-4">Final Rankings</h1>
            <div id="results-container" class="space-y-6">
                <!-- Results will be injected here -->
            </div>
            <div class="mt-6 flex flex-col sm:flex-row sm:justify-center gap-3">
                <button id="save-button" class="w-full sm:w-auto bg-indigo-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Save Results</button>
                <button id="return-to-ranking-button" class="w-full sm:w-auto bg-gray-800 text-white font-semibold py-3 px-6 rounded-lg hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-600">Return to Ranking</button>
                <button id="restart-button" class="w-full sm:w-auto bg-gray-200 text-gray-700 font-semibold py-3 px-6 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">Start Over</button>
            </div>
        </div>

    </div>

    <script>
        // --- DOM Elements ---
        const setupScreen = document.getElementById('setup-screen');
        const rankingScreen = document.getElementById('ranking-screen');
        const resultsScreen = document.getElementById('results-screen');
        const itemInput = document.getElementById('item-input');
        const startButton = document.getElementById('start-button');
        const loadTtrpgButton = document.getElementById('load-ttrpg-button');
        const progressText = document.getElementById('progress-text');
        const choiceAOption = document.getElementById('choice-a-option');
        const choiceACategory = document.getElementById('choice-a-category');
        const choiceBOption = document.getElementById('choice-b-option');
        const choiceBCategory = document.getElementById('choice-b-category');
        const choiceAButton = document.getElementById('choice-a');
        const choiceBButton = document.getElementById('choice-b');
        const undoButton = document.getElementById('undo-button');
        const showResultsButton = document.getElementById('show-results-button');
        const resultsContainer = document.getElementById('results-container');
        const saveButton = document.getElementById('save-button');
        const restartButton = document.getElementById('restart-button');
        const returnToRankingButton = document.getElementById('return-to-ranking-button');

        // --- State ---
        let categories = {};
        let categoryNames = [];
        let pairs = [];
        let currentPairIndex = 0;
        let rankings = {};
        let history = [];
        let minComparisons = 0;
        let comparisonsMade = 0;
        let currentDisplayed = { a: null, b: null };
        
        // --- Default Data ---
        const ttrpgPreferences = `Executing Cunning Plans
Executing a clever, multi-step plan that succeeds perfectly.
Setting up and executing a perfect ambush against a superior enemy force.
Infiltrating a secure location using stealth and cunning, completely undetected.
Carefully managing resources over a long journey, ending with exactly what you need at the critical moment.
Secretly orchestrating events so that two rival enemy factions weaken or destroy each other.
-
Creative Battlefield Solutions
Using the environment in a creative way to win a fight (e.g., causing a rockslide, flooding a room).
Exploiting a just-discovered weakness in the heat of battle to turn the tide of a desperate fight.
Making a series of split-second decisions during a chase scene to successfully escape or catch a target.
Using a seemingly mundane item or low-level spell in a genius way to solve a major combat problem.
Creating a perfect chokepoint or flanking opportunity through clever battlefield positioning.
-
High-Stakes Combat Triumphs
Winning a desperate, tactical battle against a single, powerful "boss" monster.
Holding the line against overwhelming waves of enemies in a desperate defense.
Defeating a recurring villain or rival who has challenged you throughout the story.
Winning a dramatic one-on-one duel against a worthy champion or antagonist.
Landing a critical hit at the most opportune moment to snatch victory from the jaws of defeat.
-
Manipulating Power Dynamics
Persuading a powerful, hostile authority figure to become an ally.
Navigating a tense, high-stakes social scene, like a royal court or a gang negotiation.
Pulling off a masterful bluff or deception to bypass a major obstacle.
Earning the genuine respect and loyalty of a skeptical community or faction.
Intimidating or blackmailing a powerful opponent to get what you want.
-
Unearthing Secrets & Clever Solutions
Uncovering a major world secret or piece of forbidden lore through research.
Figuring out an enemy's critical weakness through investigation or solving a riddle before the fight.
Exploring a completely alien, bizarre, or wondrous new location.
Solving a complex, non-combat puzzle or ancient riddle.
Finding a creative, non-violent solution to a problem that seemed to require a fight.
-
Character-Driven Victories & Comedic Moments
Having a key moment that resolves a personal goal or element from your character's backstory.
Finding a legendary magic item that dramatically changes how your character plays.
A moment of unexpected comedy or a running gag paying off spectacularly.
Seeing a long-term relationship with an NPC (friendly, romantic, or rival) reach a powerful conclusion.
Having a moment where your character fundamentally overcomes a personal flaw or fear, showing true growth.
-
Surviving Under Pressure
Making a major personal sacrifice to achieve a greater good, and then having to live with that choice.
Barely escaping from an unkillable monster or a collapsing dungeon where the only goal was to get out alive.
Using a forbidden or evil power for a good cause, and struggling with its corrupting influence.
Surviving a harsh environment with dwindling resources, where every decision about food and shelter is critical.
Being trapped inside a besieged location, knowing a terrifying threat is constantly trying to get in.
-
Overcoming Previous Limits
Unleashing a brand new, high-level ability for the first time that completely changes the scale of your power.
Effortlessly succeeding at a task that was once impossible for your character, showcasing your growth.
Displaying your power in a way that awes or terrifies a powerful NPC like a king or a dragon.
Single-handedly defeating a group of enemies that would have been a challenge for the whole party at lower levels.
Pulling off a purely cinematic, over-the-top finishing move that feels incredibly cool and stylish.
-
Cooperative Problem-Solving
Pulling off a spontaneous, multi-person combo move in combat that you've never tried before.
Having the party perfectly adopt different roles in a social encounter to manipulate the outcome.
One party member's strength perfectly covering for another's critical weakness to overcome an obstacle.
Pooling the party's collective knowledge to solve a puzzle that no single member could figure out alone.
A perfectly timed rescue or support action from another player that saves your character from certain doom.
-
Tangible Impact on the World
Founding a new guild, rebuilding a town, or establishing a stronghold that becomes a permanent fixture in the world.
Being directly responsible for changing a law or overthrowing a corrupt regime, altering the political landscape.
Having your party's deeds become a famous story, song, or legend known throughout the land.
Discovering a major new location (like a hidden valley or a lost city) and putting it on the map.
Creating a new trade route or destroying a corrupt monopoly, permanently changing a region's economy.
-`;

        // --- Event Listeners ---
        loadTtrpgButton.addEventListener('click', () => {
            itemInput.value = ttrpgPreferences;
        });
        
        startButton.addEventListener('click', () => {
            const parsedData = parseInput(itemInput.value);
            if (!parsedData) {
                itemInput.classList.add('border-red-500');
                itemInput.value = "Invalid format. Please provide at least two categories, each with at least one option, separated by '-'";
                setTimeout(() => {
                    itemInput.classList.remove('border-red-500');
                    itemInput.value = '';
                    itemInput.placeholder = "Category One\nOption A\nOption B\n-\nCategory Two\nOption C\nOption D\n-";
                }, 3000);
                return;
            }
            categories = parsedData;
            categoryNames = Object.keys(categories);
            initializeRanking();
        });

        choiceAButton.addEventListener('click', () => handleChoice(0));
        choiceBButton.addEventListener('click', () => handleChoice(1));
        undoButton.addEventListener('click', undoLastChoice);
        showResultsButton.addEventListener('click', showResults);
        returnToRankingButton.addEventListener('click', () => {
            resultsScreen.classList.add('hidden');
            rankingScreen.classList.remove('hidden');
        });
        saveButton.addEventListener('click', saveResults);
        restartButton.addEventListener('click', () => {
            // Reset all state
            categories = {};
            categoryNames = [];
            pairs = [];
            currentPairIndex = 0;
            rankings = {};
            history = [];
            minComparisons = 0;
            comparisonsMade = 0;
            itemInput.value = '';
            resultsScreen.classList.add('hidden');
            setupScreen.classList.remove('hidden');
        });

        // --- Functions ---

        function parseInput(text) {
            const tempCategories = {};
            const blocks = text.trim().split(/^-{1,}$/m);
            for (const block of blocks) {
                if (block.trim() === '') continue;
                const lines = block.trim().split('\n').map(l => l.trim()).filter(Boolean);
                if (lines.length < 2) continue;
                const categoryName = lines[0];
                const options = lines.slice(1).map(name => ({ name, shown: 0, wins: 0, losses: 0 }));
                if(options.length > 0) {
                    tempCategories[categoryName] = options;
                }
            }
            return Object.keys(tempCategories).length >= 2 ? tempCategories : null;
        }

        function initializeRanking() {
            rankings = {};
            categoryNames.forEach(name => {
                rankings[name] = { score: 1200 };
            });

            pairs = [];
            for (let i = 0; i < categoryNames.length; i++) {
                for (let j = i + 1; j < categoryNames.length; j++) {
                    pairs.push([categoryNames[i], categoryNames[j]]);
                }
            }
            
            minComparisons = Math.min(pairs.length, Math.ceil(categoryNames.length * 4));
            shuffleArray(pairs);
            
            currentPairIndex = 0;
            comparisonsMade = 0;
            history = [];
            updateUndoButton();

            setupScreen.classList.add('hidden');
            rankingScreen.classList.remove('hidden');
            resultsScreen.classList.add('hidden');
            
            displayNextPair();
        }

        function getOptionToShow(categoryName) {
            const options = categories[categoryName];
            if (!options || options.length === 0) return { name: "N/A" };
            
            let minShown = Infinity;
            options.forEach(opt => {
                if (opt.shown < minShown) {
                    minShown = opt.shown;
                }
            });

            const leastShownOptions = options.filter(opt => opt.shown === minShown);
            const chosenOption = leastShownOptions[Math.floor(Math.random() * leastShownOptions.length)];
            
            chosenOption.shown++;
            return chosenOption;
        }

        function displayNextPair() {
            // If we've gone through all pairs, reshuffle and start over.
            if (currentPairIndex >= pairs.length) {
                shuffleArray(pairs);
                currentPairIndex = 0;
            }

            const [categoryA, categoryB] = pairs[currentPairIndex];
            currentDisplayed.a = getOptionToShow(categoryA);
            currentDisplayed.b = getOptionToShow(categoryB);

            setOptionText(choiceAOption, currentDisplayed.a.name);
            setOptionText(choiceBOption, currentDisplayed.b.name);

            choiceACategory.textContent = `(Category: ${categoryA})`;
            choiceBCategory.textContent = `(Category: ${categoryB})`;
            
            progressText.textContent = `Comparison #${comparisonsMade + 1} (Minimum Recommended: ${minComparisons})`;
            showResultsButton.disabled = comparisonsMade < minComparisons;
        }

        function setOptionText(element, text) {
            element.textContent = text;
            element.classList.remove('text-2xl', 'text-xl', 'text-lg');
            
            if (text.length > 85) {
                element.classList.add('text-lg');
            } else if (text.length > 60) {
                element.classList.add('text-xl');
            } else {
                element.classList.add('text-2xl');
            }
        }

        function handleChoice(winnerIndex) {
            const [categoryA, categoryB] = pairs[currentPairIndex];
            const winningCategory = winnerIndex === 0 ? categoryA : categoryB;
            const losingCategory = winnerIndex === 0 ? categoryB : categoryA;
            const winningOption = winnerIndex === 0 ? currentDisplayed.a : currentDisplayed.b;
            const losingOption = winnerIndex === 0 ? currentDisplayed.b : currentDisplayed.a;

            history.push({
                winningCategory, losingCategory,
                winningOption, losingOption,
                oldWinnerScore: rankings[winningCategory].score,
                oldLoserScore: rankings[losingCategory].score,
            });

            updateElo(winningCategory, losingCategory);
            winningOption.wins++;
            losingOption.losses++;

            currentPairIndex++;
            comparisonsMade++;
            updateUndoButton();
            displayNextPair();
        }
        
        function undoLastChoice() {
            if (history.length === 0) return;

            const last = history.pop();
            // We can't simply decrement currentPairIndex as it might be 0 after a reshuffle.
            // Instead, we force the next pair to be the one we just undid.
            // This is a simplification; a more robust undo would require saving the shuffled list state.
            // For now, we'll just go back one comparison and let the next pair be whatever it is.
            comparisonsMade--;
            
            rankings[last.winningCategory].score = last.oldWinnerScore;
            rankings[last.losingCategory].score = last.oldLoserScore;
            last.winningOption.wins--;
            last.winningOption.shown--;
            last.losingOption.losses--;
            last.losingOption.shown--;
            
            // Re-enable the results button if we go below the minimum
            showResultsButton.disabled = comparisonsMade < minComparisons;
            progressText.textContent = `Comparison #${comparisonsMade + 1} (Minimum Recommended: ${minComparisons})`;
            updateUndoButton();
            // Note: The displayed pair will not be the one just undone, but the next in the sequence.
            // This is a trade-off for the indefinite ranking feature.
        }
        
        function updateUndoButton() {
            undoButton.disabled = history.length === 0;
        }

        function updateElo(winner, loser) {
            const K = 32;
            const ratingWinner = rankings[winner].score;
            const ratingLoser = rankings[loser].score;
            const expectedWinner = 1 / (1 + Math.pow(10, (ratingLoser - ratingWinner) / 400));
            rankings[winner].score = ratingWinner + K * (1 - expectedWinner);
            rankings[loser].score = ratingLoser + K * (0 - (1 - expectedWinner));
        }

        function showResults() {
            rankingScreen.classList.add('hidden');
            resultsScreen.classList.remove('hidden');

            const sortedCategories = Object.entries(rankings).sort(([, a], [, b]) => b.score - a.score);

            resultsContainer.innerHTML = '';

            const categorySection = document.createElement('div');
            categorySection.innerHTML = `<h2 class="text-xl font-bold text-gray-800 mb-3">Category Rankings</h2>`;
            const categoryList = document.createElement('div');
            categoryList.className = 'bg-gray-50 border border-gray-200 rounded-lg p-2 space-y-2';
            
            sortedCategories.forEach(([name, data], index) => {
                const rankElement = document.createElement('div');
                rankElement.className = 'flex items-center justify-between p-3 rounded-md';
                rankElement.classList.add(index % 2 === 0 ? 'bg-white' : 'bg-gray-100');
                rankElement.innerHTML = `
                    <div class="flex items-center">
                        <span class="text-lg font-bold text-gray-500 w-8 text-center">${index + 1}</span>
                        <span class="ml-4 text-lg font-medium text-gray-800">${name}</span>
                    </div>
                    <span class="text-sm font-mono text-gray-600">${Math.round(data.score)}</span>`;
                categoryList.appendChild(rankElement);
            });
            categorySection.appendChild(categoryList);
            resultsContainer.appendChild(categorySection);

            const detailsSection = document.createElement('div');
            detailsSection.innerHTML = `<h2 class="text-xl font-bold text-gray-800 mb-3 mt-6">Option Performance Details</h2>`;
            const detailsList = document.createElement('div');
            detailsList.className = 'space-y-4';

            sortedCategories.forEach(([categoryName]) => {
                const categoryDetail = document.createElement('div');
                categoryDetail.innerHTML = `<h3 class="font-semibold text-gray-700">${categoryName}</h3>`;
                const optionsList = document.createElement('ul');
                optionsList.className = 'list-disc list-inside text-gray-600 text-sm pl-2';
                categories[categoryName].forEach(opt => {
                    const optionItem = document.createElement('li');
                    optionItem.textContent = `${opt.name} (Won: ${opt.wins}, Lost: ${opt.losses}, Shown: ${opt.shown})`;
                    optionsList.appendChild(optionItem);
                });
                categoryDetail.appendChild(optionsList);
                detailsList.appendChild(categoryDetail);
            });
            detailsSection.appendChild(detailsList);
            resultsContainer.appendChild(detailsSection);
        }
        
        function saveResults() {
            const sortedCategories = Object.entries(rankings).sort(([, a], [, b]) => b.score - a.score);
            const dataToSave = {
                categoryRankings: sortedCategories.map(([name, data], index) => ({
                    category: name,
                    rank: index + 1,
                    score: Math.round(data.score)
                })),
                optionDetails: categories,
                comparisonsMade: comparisonsMade,
                timestamp: new Date().toISOString()
            };

            const blob = new Blob([JSON.stringify(dataToSave, null, 2)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `ranking-results-${new Date().getTime()}.json`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }

        function shuffleArray(array) {
            for (let i = array.length - 1; i > 0; i--) {
                const j = Math.floor(Math.random() * (i + 1));
                [array[i], array[j]] = [array[j], array[i]];
            }
        }
    </script>
</body>
</html>