Skip to content

Instantly share code, notes, and snippets.

@pakoito
Last active March 14, 2026 23:05
Show Gist options
  • Select an option

  • Save pakoito/839fadb61de0f5c5ddc2b2e31b2a1cad to your computer and use it in GitHub Desktop.

Select an option

Save pakoito/839fadb61de0f5c5ddc2b2e31b2a1cad to your computer and use it in GitHub Desktop.
Manabase Auto-Analyzer - Greasemonkey script for Salubrious Snail
// ==UserScript==
// @name Manabase Tool Auto-Analyzer
// @namespace http://tampermonkey.net/
// @version 3.6.3
// @description Auto-triggers analyzers, maintains history, and optimizes basic land distribution
// @author pakoito
// @match https://ianrh125.github.io/snail-analyzer/
// @icon https://www.google.com/s2/favicons?sz=64&domain=github.io
// @require https://gist.githubusercontent.com/pakoito/5c7f9b8c35efee0126b2b874beb365db/raw/manabase-optimizer-bundle.js?v=1773529527253
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect getcards-4zjvieuafa-uc.a.run.app
// @run-at document-start
// ==/UserScript==
/**
* Manabase Tool Auto-Analyzer v3.5.0
*
* PURPOSE:
* Automatically triggers the Color Analyzer and Tap Analyzer buttons after a deck
* successfully loads in the Salubrious Snail Manabase Tool, maintains a complete
* history of all deck analyses, and optimizes basic land distribution using a
* hill-climbing algorithm.
*
* FEATURES:
* 1. Auto-Trigger: Automatically runs both analyzers when deck loads
* 2. History Capture: Saves up to 100 analysis results automatically
* 3. Diff Tracking: Shows what changed between deck versions
* 4. One-Click Restore: Load any previous deck state and re-run analysis
* 5. Clipboard Paste: Paste deck from clipboard with auto-commander detection
* 6. Basic Land Optimizer: Find optimal basic land distribution for your deck
* 7. localStorage Storage: Persists history across browser sessions
*
* BASIC LAND OPTIMIZER:
* The optimizer uses a hill-climbing algorithm to find better basic land distributions:
* 1. Extracts your deck list and current basic land counts
* 2. Fetches card data from Scryfall API
* 3. Runs the website's color calculation algorithm on different land configurations
* 4. Tests neighbors (1-land swaps) and explores improvements
* 5. Returns top 5 configurations ranked by cast rate and average delay
*
* The optimizer identifies your weakest color and suggests adding more of those lands
* while removing from stronger colors. It preserves total land count and maintains
* minimum 1 of each type present in your deck.
*
* HOW IT WORKS:
* Auto-Trigger:
* 1. Monitors #load-result for "Deck loaded!" message
* 2. Auto-triggers Color and Tap Analyzers with 200ms delay
* 3. Watches analyzer result boxes for completion
* 4. Captures complete snapshot when both analyzers finish
* 5. Stores in localStorage with 100-entry FIFO limit
* 6. Displays in expandable history panel below analyzers
*
* Optimizer:
* 1. Click "Optimize Basic Lands" button in optimizer panel
* 2. Script extracts deck list from #decklist textarea
* 3. Counts basic lands (Plains, Island, Swamp, Mountain, Forest, Wastes)
* 4. Calls ManabaseOptimizer.optimizeLands() with deck data
* 5. Shows progress during card loading and optimization
* 6. Displays top 5 configurations with cast rates and changes needed
*
* HISTORY DATA CAPTURED:
* - Deck list (trimmed, max 110 lines)
* - Commander names and importance weights
* - Manabase metrics (cast rate, average delay)
* - Commander-specific cast rates and delays
* - Color Analyzer summary
* - Tap statistics
* - Ignore/discount list (auto-commits on edit after 5s debounce)
* - Card-by-card diff from previous entry
*
* KEY ELEMENTS:
* - #load-result: Deck load status message
* - #color-compute-button, #tap-compute-button: Analyzer triggers
* - #color-analyzer-result-box, #tap-analyzer-result-box: Result containers
* - #optimizer-panel: Optimizer UI (created by script)
* - #history-panel: History UI (created by script)
* - localStorage['manabase-history']: Persistent storage
* - ManabaseOptimizer: Global object from bundled optimizer module
*
* EXTERNAL DEPENDENCIES:
* - Optimizer Bundle: Loaded via @require from GitHub Gist
* https://gist.github.com/pakoito/5c7f9b8c35efee0126b2b874beb365db
* - Scryfall API: Card data fetching (rate limited to 50-100ms between requests)
*
* SAFETY FEATURES:
* - Validates all DOM elements before access
* - Graceful degradation for missing data
* - Emergency trim if localStorage quota exceeded
* - Corrupted data detection and cleanup
* - Comprehensive error handling and logging
* - Confirmation dialog for clearing history
* - Progress feedback during optimization
* - Button disabled while optimizer is running
*
* STORAGE:
* Uses localStorage with key 'manabase-history'. FIFO enforced at 100 entries.
* Typical storage: ~50-100KB for 100 entries. Emergency trim to 50 if quota exceeded.
*
* PERFORMANCE:
* - Optimizer typically tests 20-50 configurations
* - Each configuration takes ~2-3 seconds (Scryfall rate limiting)
* - Total optimization time: 1-2 minutes for most decks
* - Results cached to avoid redundant calculations
*/
(function () {
'use strict';
/**
* Script version - synced automatically with @version header during build
*/
const SCRIPT_VERSION = '3.6.3';
/**
* Debug flag - set to true to enable verbose logging
* When false, only logs errors, warnings, and major actions
*/
const DEBUG = true;
// Helper logging functions
function logDebug(...args) {
if (DEBUG) console.log(...args);
}
function logInfo(...args) {
console.log(...args);
}
function logWarn(...args) {
console.warn(...args);
}
function logError(...args) {
console.error(...args);
}
logInfo('Manabase Auto-Analyzer: Script loaded');
/**
* State Management
*
* Track the last load message that triggered the analyzers to prevent
* duplicate triggers. The load result text changes to "Deck loaded! (1)",
* "Deck loaded! (2)", etc. for sequential loads, so we compare the full text.
*/
let lastTriggeredText = '';
/**
* Initialize the script once DOM is ready
*/
function initializeScript() {
/**
* Element Detection
*
* The #load-result element contains the deck load status message.
* It displays "Deck loaded!" on success or error messages on failure.
* If this element doesn't exist, the page structure has likely changed
* and the script cannot function.
*/
const loadResult = document.getElementById('load-result');
if (!loadResult) {
logError('Manabase Auto-Analyzer: load-result element not found');
return;
}
logInfo('Manabase Auto-Analyzer: Monitoring deck loads...');
startMonitoring(loadResult);
}
/**
* Start monitoring for deck loads
*/
function startMonitoring(loadResult) {
/**
* Auto-Trigger Function
*
* Triggers both Color Analyzer and Tap Analyzer in sequence.
*
* EXECUTION ORDER:
* 1. Verify both buttons exist and are enabled
* 2. Click Color Analyzer button
* 3. Wait 200ms (gives time for Color Analyzer to initialize)
* 4. Click Tap Analyzer button
*
* WHY THE DELAY:
* The 200ms delay between clicks ensures the Color Analyzer has time to
* start its computation before the Tap Analyzer begins. This prevents
* potential race conditions or resource conflicts. You can adjust this
* value if needed (increase if analyzers conflict, decrease to speed up).
*
* ERROR HANDLING:
* - Wrapped in try-catch to handle unexpected errors gracefully
* - Checks for button existence before clicking
* - Verifies buttons are enabled (not disabled)
* - Second try-catch for the delayed Tap Analyzer click
*/
function triggerAnalyzers() {
try {
// Reset analyzer completion flags before triggering
resetAnalyzerFlags();
// Get references to both analyzer buttons
const colorButton = document.getElementById('color-compute-button');
const tapButton = document.getElementById('tap-compute-button');
// Verify both buttons exist before proceeding
if (!colorButton || !tapButton) {
logWarn('Manabase Auto-Analyzer: One or more analyzer buttons not found');
return;
}
// Check if Color button is enabled (disabled during computation)
if (colorButton.disabled) {
logWarn('Manabase Auto-Analyzer: Color button is disabled, skipping auto-trigger');
return;
}
// Trigger Color Analyzer
logInfo('Manabase Auto-Analyzer: Triggering Color Analyzer...');
colorButton.click();
/**
* Delayed Tap Analyzer Trigger
*
* Use setTimeout to create a 200ms delay before clicking the Tap Analyzer.
* This ensures the Color Analyzer has started before we trigger the second one.
*/
setTimeout(() => {
try {
// Re-verify button exists and is enabled (state may have changed)
if (tapButton && !tapButton.disabled) {
logInfo('Manabase Auto-Analyzer: Triggering Tap Analyzer...');
tapButton.click();
} else {
logWarn('Manabase Auto-Analyzer: Tap button not available or disabled');
}
} catch (error) {
logError('Manabase Auto-Analyzer: Error triggering Tap Analyzer:', error);
}
}, 200); // 200ms delay - adjust if needed
} catch (error) {
logError('Manabase Auto-Analyzer: Error in triggerAnalyzers:', error);
}
}
/**
* MutationObserver Setup
*
* WHY MUTATIONOBSERVER:
* We use MutationObserver instead of polling (setInterval) because it's more
* efficient and responsive. The observer fires immediately when the DOM changes,
* whereas polling would check on a fixed schedule and could miss rapid changes
* or waste resources checking when nothing has changed.
*
* WHAT IT WATCHES:
* Monitors the #load-result element for any text content changes. When the user
* clicks the "Load" button, the deck loading function updates this element with
* either "Deck loaded!" (success) or an error message (failure).
*
* OBSERVATION CONFIGURATION:
* - childList: true → Watches for added/removed child nodes
* - characterData: true → Watches for text content changes
* - subtree: true → Watches all descendants, not just direct children
*
* These options ensure we catch the text change regardless of how the page
* updates the element (direct text, innerHTML, child nodes, etc.).
*/
const observer = new MutationObserver((mutations) => {
try {
// Get the current text content of the load result element
const loadText = loadResult.innerText.trim();
/**
* Success Detection
*
* Check if the deck loaded successfully by looking for "Deck loaded!".
* The message format is:
* - First load: "Deck loaded!"
* - Second load: "Deck loaded! (1)"
* - Third load: "Deck loaded! (2)"
* etc.
*/
if (loadText.includes('Deck loaded!')) {
/**
* Duplicate Prevention
*
* The MutationObserver may fire multiple times for the same change
* (due to multiple mutations in the DOM). We track the last message
* that triggered the analyzers and skip if it's the same.
*/
if (loadText === lastTriggeredText) {
return;
}
// Update state and trigger analyzers
lastTriggeredText = loadText;
logInfo('Manabase Auto-Analyzer: Deck loaded successfully, triggering analyzers');
// Update complexity warnings (analyze deck once, pass to both)
const complexity = analyzeDeckComplexity();
updateColorAnalyzerComplexityWarning(complexity);
updateOptimizerComplexityWarning(complexity);
triggerAnalyzers();
// Clear optimizer results and enable controls
const optimizeBtn = document.getElementById('optimize-lands-btn');
const statusDiv = document.getElementById('optimizer-status');
const resultsDiv = document.getElementById('optimizer-results');
if (optimizeBtn) {
optimizeBtn.disabled = false;
optimizeBtn.value = 'Optimize Basic Lands';
optimizeBtn.style.backgroundColor = '';
optimizeBtn.style.color = '';
optimizeBtn.onclick = null; // Clear any cancel handler
logDebug('Optimizer: Button enabled after deck load');
}
// Enable inputs in options row
const preserveCheckbox = document.getElementById('optimizer-preserve-types');
const timeoutInput = document.getElementById('optimizer-timeout');
if (preserveCheckbox) preserveCheckbox.disabled = false;
if (timeoutInput) timeoutInput.disabled = false;
logDebug('Optimizer: Options enabled after deck load');
// Enable power level button and auto-calculate
const powerLevelBtn = document.getElementById('powerlevel-calc-btn');
if (powerLevelBtn) {
powerLevelBtn.disabled = false;
logDebug('PowerLevel: Button enabled after deck load');
// Auto-trigger power level calculation after a short delay
setTimeout(() => {
logDebug('PowerLevel: Auto-triggering calculation');
calculatePowerLevel();
}, 500);
}
if (resultsDiv) {
resultsDiv.innerHTML = '';
resultsDiv.style.display = 'none';
logDebug('Optimizer: Results cleared after deck load');
}
if (statusDiv) {
// Reset status to ready
statusDiv.textContent = 'Ready to optimize';
statusDiv.style.color = '#555';
}
} else if (loadText.toLowerCase().includes('error')) {
/**
* Error Detection
*
* If the deck load failed (invalid format, missing commander, etc.),
* the load result will contain an error message. We don't trigger
* the analyzers in this case since there's no valid deck to analyze.
*
* We also reset lastTriggeredText so the next successful load will
* trigger even if it happens to have the same counter number.
*/
logInfo('Manabase Auto-Analyzer: Deck load failed, skipping auto-trigger');
lastTriggeredText = ''; // Reset for next attempt
}
} catch (error) {
// Catch any unexpected errors to prevent the observer from breaking
logError('Manabase Auto-Analyzer: Error in MutationObserver callback:', error);
}
});
/**
* Start Observing
*
* Begin watching the #load-result element for changes. The observer will
* continue running until the page is closed or refreshed.
*/
observer.observe(loadResult, {
childList: true, // Watch for added/removed child nodes
characterData: true, // Watch for text content changes
subtree: true // Watch all descendants
});
}
/**
* =================================================================
* CLIPBOARD PASTE FEATURE
* =================================================================
*/
// Fix UTF-8 double-encoding: text was UTF-8, misread as Windows-1252, then re-encoded as UTF-8
// Reverse by mapping each codepoint back to its Windows-1252 byte value and re-decoding as UTF-8
//
// Windows-1252 bytes 0x80-0x9F map to different Unicode codepoints than their byte values.
// This table maps those Unicode codepoints back to original byte values.
const cp1252ToByte = {
0x20AC: 0x80, // €
0x201A: 0x82, // ‚
0x0192: 0x83, // ƒ
0x201E: 0x84, // „
0x2026: 0x85, // …
0x2020: 0x86, // †
0x2021: 0x87, // ‡
0x02C6: 0x88, // ˆ
0x2030: 0x89, // ‰
0x0160: 0x8A, // Š
0x2039: 0x8B, // ‹
0x0152: 0x8C, // Œ
0x017D: 0x8E, // Ž
0x2018: 0x91, // '
0x2019: 0x92, // '
0x201C: 0x93, // "
0x201D: 0x94, // "
0x2022: 0x95, // •
0x2013: 0x96, // –
0x2014: 0x97, // —
0x02DC: 0x98, // ˜
0x2122: 0x99, // ™
0x0161: 0x9A, // š
0x203A: 0x9B, // ›
0x0153: 0x9C, // œ
0x017E: 0x9E, // ž
0x0178: 0x9F, // Ÿ
};
function fixMojibake(text) {
try {
const bytes = [];
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 256) {
bytes.push(code);
} else if (cp1252ToByte[code] !== undefined) {
// Windows-1252 special character - map back to original byte
bytes.push(cp1252ToByte[code]);
} else {
// Character not in Windows-1252 range - not double-encoded, return original
return text;
}
}
const decoded = new TextDecoder('utf-8').decode(new Uint8Array(bytes));
// If decoding produced replacement characters, original wasn't double-encoded
if (decoded.includes('\uFFFD')) {
return text;
}
return decoded;
} catch (e) {
return text;
}
}
/**
* Add clipboard paste button to Deck Loader section
*
* Supports two deck list formats:
*
* 1. Legacy format:
* - Lines starting with // are skipped (comments)
* - "// COMMANDER" marks commander section
* - Empty line ends commander section
* - Remaining lines are deck cards
*
* 2. Bracket format:
* - [SECTION_NAME] headers (e.g., [COMMANDER], [CREATURES], [LANDS])
* - [COMMANDER] section contains commanders
* - [SIDEBOARD] and [MAYBEBOARD] sections are excluded
* - All other sections are included in deck list
*
* Sets commander weights to 30 and auto-loads the deck.
*/
function createClipboardPasteButton() {
try {
// Find the Deck Loader title/header
// Look for the element containing "Deck Loader" text
const titles = document.querySelectorAll('.title, h1, h2, h3, p');
let deckLoaderTitle = null;
for (let title of titles) {
if (title.textContent.trim() === 'Deck Loader') {
deckLoaderTitle = title;
break;
}
}
if (!deckLoaderTitle) {
logWarn('Clipboard: Deck Loader title not found');
return;
}
// Check if button already exists
if (document.getElementById('clipboard-paste-btn')) {
logDebug('Clipboard: Button already exists');
return;
}
// Create paste button paragraph (match Load button structure)
const pastePara = document.createElement('p');
const pasteButton = document.createElement('input');
pasteButton.type = 'button';
pasteButton.id = 'clipboard-paste-btn';
pasteButton.value = 'Paste from Clipboard';
// No fixed width - let button auto-size to fit text
pastePara.appendChild(pasteButton);
// Insert button paragraph after the title
deckLoaderTitle.parentNode.insertBefore(pastePara, deckLoaderTitle.nextSibling);
// Attach click handler
pasteButton.addEventListener('click', function (e) {
logDebug('Clipboard: Paste button clicked');
// Save current scroll position
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
logDebug('Clipboard: Saved scroll position:', scrollX, scrollY);
// Create a hidden textarea to capture paste event
const tempTextarea = document.createElement('textarea');
tempTextarea.style.cssText = 'position: fixed; left: -9999px; top: -9999px; opacity: 0;';
tempTextarea.id = 'temp-clipboard-paste';
document.body.appendChild(tempTextarea);
// Focus with preventScroll option to avoid jumping
tempTextarea.focus({ preventScroll: true });
// Restore scroll position (in case preventScroll doesn't work in all browsers)
window.scrollTo(scrollX, scrollY);
logDebug('Clipboard: Temporary textarea created and focused');
logDebug('Clipboard: Waiting for paste event (Ctrl+V)...');
// Show a temporary instruction
pasteButton.value = 'Press Ctrl+V to paste';
pasteButton.style.backgroundColor = '#3498db';
pasteButton.style.color = 'white';
// Listen for paste event
const pasteHandler = function (pasteEvent) {
logDebug('Clipboard: Paste event detected');
pasteEvent.preventDefault();
// Reset button
pasteButton.value = 'Paste from Clipboard';
pasteButton.style.backgroundColor = '';
pasteButton.style.color = '';
let clipboardText = '';
// Get pasted data
if (pasteEvent.clipboardData && pasteEvent.clipboardData.getData) {
clipboardText = pasteEvent.clipboardData.getData('text');
// Fix UTF-8 mojibake (double-encoding issues)
clipboardText = fixMojibake(clipboardText);
}
// Remove temp textarea and event listener
tempTextarea.removeEventListener('paste', pasteHandler);
document.body.removeChild(tempTextarea);
if (!clipboardText || !clipboardText.trim()) {
logDebug('Clipboard: No data pasted');
alert('No data pasted. Please try again.');
return;
}
try {
// Process the deck list
const lines = clipboardText.split('\n');
const commanders = [];
const deckCards = [];
let inCommanderSection = false;
let foundEmptyLine = false;
let currentSection = null; // Track current bracket section
let inSkipSection = false; // Track if in SIDEBOARD/MAYBEBOARD
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check for bracket-style section headers [SECTION_NAME]
if (line.startsWith('[') && line.endsWith(']')) {
const sectionName = line.substring(1, line.length - 1).toUpperCase();
currentSection = sectionName;
logDebug('Clipboard: Detected section:', sectionName);
// Check if this is a section we should skip
if (sectionName === 'SIDEBOARD' || sectionName === 'MAYBEBOARD') {
inSkipSection = true;
inCommanderSection = false;
logDebug('Clipboard: Entering skip section:', sectionName);
} else {
inSkipSection = false;
if (sectionName === 'COMMANDER') {
inCommanderSection = true;
logDebug('Clipboard: Entering commander section');
} else {
inCommanderSection = false;
}
}
continue; // Skip the section header line itself
}
// Check for // COMMANDER comment (legacy format)
if (line.startsWith('// COMMANDER')) {
inCommanderSection = true;
currentSection = 'COMMANDER';
continue;
}
// Empty line marks end of commander section (legacy format)
if (!line) {
if (inCommanderSection && currentSection === 'COMMANDER') {
// Legacy format: // COMMANDER followed by cards, then empty line
foundEmptyLine = true;
inCommanderSection = false;
currentSection = null;
logDebug('Clipboard: Exiting commander section (empty line)');
}
continue;
}
// Skip cards if we're in a skip section
if (inSkipSection) {
logDebug('Clipboard: Skipping card in', currentSection + ':', line.substring(0, 50));
continue;
}
// Skip other comment lines (but not dual-faced cards like "Farm // Market")
if (line.startsWith('//')) {
logDebug('Clipboard: Skipping comment line:', line.substring(0, 50));
continue;
}
// Add to commanders or deck based on section
if (inCommanderSection || currentSection === 'COMMANDER') {
commanders.push(line);
logDebug('Clipboard: Found commander:', line);
} else if (foundEmptyLine || currentSection) {
// In bracket format, any non-skip section adds to deck
// In legacy format, after empty line adds to deck
deckCards.push(line);
} else {
// Fallback: before empty line but no commander marker (legacy format)
// Treat first line as commander
if (commanders.length === 0) {
commanders.push(line);
logDebug('Clipboard: First line as commander:', line);
} else {
deckCards.push(line);
}
}
}
// Sort deck cards: basics first (in WUBRG order), then alphabetically
const basicLands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes'];
const basics = [];
const nonBasics = [];
for (const card of deckCards) {
// Check if it's a basic land by checking if the line contains the land name
const lowerCard = card.toLowerCase();
const isBasic = basicLands.some(basic =>
lowerCard.includes(basic.toLowerCase())
);
if (isBasic) {
basics.push(card);
} else {
nonBasics.push(card);
}
}
// Sort basics in WUBRG(C) order by land name
basics.sort((a, b) => {
const lowerA = a.toLowerCase();
const lowerB = b.toLowerCase();
// Find which basic land type each is
const indexA = basicLands.findIndex(basic => lowerA.includes(basic.toLowerCase()));
const indexB = basicLands.findIndex(basic => lowerB.includes(basic.toLowerCase()));
return indexA - indexB;
});
// Sort non-basics alphabetically
nonBasics.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
// Combine: commanders first, then basics, then other cards
const filteredLines = [...commanders, ...basics, ...nonBasics];
logDebug('Clipboard: Parsed', commanders.length, 'commanders,', basics.length, 'basics,', nonBasics.length, 'other cards');
if (commanders.length === 0) {
alert('No valid commander found in deck list');
return;
}
// Extract commander names from lines (remove quantity prefix)
const extractCommanderName = (line) => {
const match = line.match(/^\d+x?\s+(.+)$/i);
return match ? match[1].trim() : line;
};
const commanderNames = commanders.map(extractCommanderName);
logDebug('Clipboard: Extracted commander names:', commanderNames);
// Fill commander fields
const commander1Input = document.getElementById('commander-name-1');
const commander2Input = document.getElementById('commander-name-2');
const commander3Input = document.getElementById('commander-name-3');
logDebug('Clipboard: Commander input fields found:', {
commander1: !!commander1Input,
commander2: !!commander2Input,
commander3: !!commander3Input
});
if (commander1Input) {
commander1Input.value = commanderNames[0] || '';
logDebug('Clipboard: Set commander 1 to:', commanderNames[0] || '(cleared)');
}
if (commander2Input) {
commander2Input.value = commanderNames[1] || '';
logDebug('Clipboard: Set commander 2 to:', commanderNames[1] || '(cleared)');
}
if (commander3Input) {
commander3Input.value = commanderNames[2] || '';
logDebug('Clipboard: Set commander 3 to:', commanderNames[2] || '(cleared)');
}
// Set commander weights (all to 30)
const weight1Select = document.getElementById('cmdr1-weight');
const weight2Select = document.getElementById('cmdr2-weight');
const weight3Select = document.getElementById('cmdr3-weight');
if (weight1Select) {
weight1Select.value = '30';
}
if (weight2Select) {
weight2Select.value = commanderNames[1] ? '30' : '10';
}
if (weight3Select) {
weight3Select.value = commanderNames[2] ? '30' : '10';
}
// Fill deck list
const decklistTextarea = document.getElementById('decklist');
if (decklistTextarea) {
decklistTextarea.value = filteredLines.join('\n');
}
logDebug('Clipboard: Successfully processed', filteredLines.length, 'lines, commanders:', commanderNames);
// Auto-trigger deck load
const loadButton = document.getElementById('deck-load-button');
if (loadButton && !loadButton.disabled) {
logDebug('Clipboard: Auto-triggering deck load');
setTimeout(() => {
loadButton.click();
logDebug('Clipboard: Load button clicked, analyzers will auto-run');
}, 100); // Small delay to ensure form is populated
} else {
logWarn('Clipboard: Load button not available or disabled');
}
} catch (error) {
logError('Clipboard: Error processing pasted data:', error);
alert('Error processing deck list: ' + error.message);
}
};
tempTextarea.addEventListener('paste', pasteHandler);
// Timeout to reset button if no paste within 10 seconds
setTimeout(() => {
if (document.getElementById('temp-clipboard-paste')) {
logDebug('Clipboard: Paste timeout, cleaning up');
tempTextarea.removeEventListener('paste', pasteHandler);
document.body.removeChild(tempTextarea);
pasteButton.value = 'Paste from Clipboard';
pasteButton.style.backgroundColor = '';
pasteButton.style.color = '';
}
}, 10000);
});
logDebug('Clipboard: Paste button created');
} catch (error) {
logError('Clipboard: Error creating paste button:', error);
}
}
/**
* =================================================================
* INITIALIZATION
* =================================================================
*/
/**
* Additional discounts to add to the website's commonDiscounts object
*
* Format: { "Card Name": discountAmount }
*
* These are merged with the website's existing discounts on page load.
* If a card already exists in commonDiscounts, our value will override it.
*/
const ADDITIONAL_DISCOUNTS = {
// Miracle cards (discount = CMC - miracle cost)
"Banishing Stroke": 5,
"Blessings of Nature": 4,
"Devastation Tide": 3,
"Entreat the Angels": 1,
"Entreat the Dead": 1,
"Lorehold, the Historian": 3,
"Metamorphosis Fanatic": 4,
"Redress Fate": 4,
"Reforge the Soul": 3,
"Revenge of the Hunted": 5,
"Sister Repentia": 3,
"Temporal Mastery": 5,
"Terminus": 5,
"Thunderous Wrath": 5,
"Triumph of Saint Katherine": 3,
"Vanishment": 4,
"Zephyrim": 2,
// Duskmourn Overlords with Impending (discount = CMC - impending cost)
"Overlord of the Balemurk": 3,
"Overlord of the Boilerbilges": 2,
"Overlord of the Floodpits": 2,
"Overlord of the Hauntwoods": 2,
"Overlord of the Mistmoors": 3,
};
/**
* Patch the website's commonDiscounts with additional entries
*
* The website defines commonDiscounts as a const in a script tag.
* It's not on window, so we inject a script element to run in page context.
*/
function patchCommonDiscounts() {
// Create script content that will run in the page's context
const scriptContent = `
(function() {
if (typeof commonDiscounts === 'undefined') {
console.warn('DiscountPatch: commonDiscounts not found');
return;
}
const additionalDiscounts = ${JSON.stringify(ADDITIONAL_DISCOUNTS)};
let patchedCount = 0;
for (const [cardName, discount] of Object.entries(additionalDiscounts)) {
commonDiscounts[cardName] = discount;
patchedCount++;
}
console.log('DiscountPatch: Added/updated ' + patchedCount + ' discount(s)');
})();
`;
// Inject script into page
const script = document.createElement('script');
script.textContent = scriptContent;
document.head.appendChild(script);
script.remove(); // Clean up after execution
logDebug('DiscountPatch: Injected patch script into page context');
}
/**
* Normalize ignore/discount list for deterministic storage
*
* Sorts the list as follows:
* 1. Lines starting with `-` (ignores) - sorted alphabetically
* 2. Lines starting with a number (discounts) - sorted alphabetically
*
* @param {string} ignoreList - Raw ignore list text
* @returns {string} Normalized ignore list
*/
function normalizeIgnoreList(ignoreList) {
if (!ignoreList || typeof ignoreList !== 'string') {
return '';
}
const lines = ignoreList.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Separate ignores (start with -) from discounts (start with number)
const ignores = lines.filter(line => line.startsWith('-'));
const discounts = lines.filter(line => /^\d/.test(line));
// Keep any other lines that don't match either pattern
const other = lines.filter(line => !line.startsWith('-') && !/^\d/.test(line));
// Sort each group alphabetically (case-insensitive)
ignores.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
discounts.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
other.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Combine: ignores first, then discounts, then other
return [...ignores, ...discounts, ...other].join('\n');
}
/**
* Fix: Clear ignore/discount list when loading a new deck
*
* The original website has a bug where the ignore textarea is never cleared
* when loading a deck (line 990 in index.html is commented out). This causes:
* - Discounts from previous decks to persist
* - Duplicate discounts when reloading the same deck
*
* This fix wraps the global loadDict function to clear the ignore textarea
* before the original function runs.
*
* Exception: When restoring from history, the ignore textarea will contain
* saved ignores that match a history entry for the current deck. We detect
* this by comparing the current values against history - no state needed.
*/
function setupIgnoreListClearingFix() {
// Check if loadDict exists on window
if (typeof window.loadDict !== 'function') {
logWarn('IgnoreFix: loadDict not found on window, retrying in 500ms');
setTimeout(setupIgnoreListClearingFix, 500);
return;
}
// Store reference to original function
const originalLoadDict = window.loadDict;
// Wrap loadDict with our clearing logic
window.loadDict = function(...args) {
const ignoreTextarea = document.getElementById('ignore');
const decklistTextarea = document.getElementById('decklist');
let savedIgnores = null; // Will hold saved ignores to restore after loadDict
if (ignoreTextarea && decklistTextarea) {
const currentIgnoreValue = ignoreTextarea.value.trim();
const currentDeckList = decklistTextarea.value.trim();
// Check if current ignore value matches a history entry for this deck
// If so, we're restoring from history and should preserve the ignores EXACTLY
// (don't let loadDict add more from commonDiscounts)
let shouldPreserve = false;
if (currentIgnoreValue.length > 0) {
const history = loadHistory();
shouldPreserve = history.some(entry =>
entry.deckList &&
entry.deckList.trim() === currentDeckList &&
entry.ignoreList &&
entry.ignoreList.trim() === currentIgnoreValue
);
}
if (shouldPreserve) {
// Save the ignores to restore after loadDict
// This prevents loadDict from adding commonDiscounts that user may have removed
savedIgnores = currentIgnoreValue;
logDebug('IgnoreFix: Will restore saved ignores after loadDict');
}
// Always clear before loadDict - we'll restore saved ignores after if needed
ignoreTextarea.value = '';
logDebug('IgnoreFix: Cleared ignore/discount list before deck load');
}
// Call original loadDict
const result = originalLoadDict.apply(this, args);
// After loadDict completes, restore saved ignores or deduplicate
if (result && typeof result.then === 'function') {
result.then(() => {
if (savedIgnores !== null) {
// Restore the exact saved ignores, ignoring what loadDict added
ignoreTextarea.value = savedIgnores;
logDebug('IgnoreFix: Restored saved ignores (ignoring loadDict additions)');
} else {
// Fresh load - just deduplicate in case of any duplicates
deduplicateIgnoreList();
}
}).catch(() => {
// Ignore errors
});
}
return result;
};
logDebug('IgnoreFix: loadDict wrapper installed');
}
/**
* Remove duplicate lines from the ignore textarea
*/
function deduplicateIgnoreList() {
const ignoreTextarea = document.getElementById('ignore');
if (!ignoreTextarea) return;
const lines = ignoreTextarea.value.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Use Set to remove duplicates while preserving order
const seen = new Set();
const unique = [];
for (const line of lines) {
if (!seen.has(line)) {
seen.add(line);
unique.push(line);
}
}
const deduplicated = unique.join('\n');
if (deduplicated !== ignoreTextarea.value.trim()) {
ignoreTextarea.value = deduplicated;
logDebug('IgnoreFix: Deduplicated ignore list');
}
}
/**
* Debounce delay for auto-committing ignore list changes to history (in ms)
*/
const IGNORE_LIST_DEBOUNCE_MS = 2000;
/**
* Auto-commit ignore/discount list changes to history
*
* When the user edits the ignore textarea, we debounce and then update
* the most recent history entry with the new ignore list value.
*/
function setupIgnoreListAutoCommit() {
const ignoreTextarea = document.getElementById('ignore');
if (!ignoreTextarea) {
logWarn('IgnoreAutoCommit: Ignore textarea not found');
return;
}
let debounceTimer = null;
ignoreTextarea.addEventListener('input', function(e) {
// Clear any pending debounce
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Schedule the auto-commit
debounceTimer = setTimeout(() => {
const newIgnoreList = normalizeIgnoreList(ignoreTextarea.value);
try {
const history = loadHistory();
if (history.length === 0) {
logDebug('IgnoreAutoCommit: No history entries to update');
return;
}
// Update the most recent entry
const latestEntry = history[history.length - 1];
// Only update if actually changed
if (latestEntry.ignoreList === newIgnoreList) {
logDebug('IgnoreAutoCommit: No change detected, skipping');
return;
}
latestEntry.ignoreList = newIgnoreList;
saveHistory(history);
logDebug('IgnoreAutoCommit: Updated latest history entry with new ignore list');
// Refresh the UI to show the updated ignore list
renderHistoryPanel();
} catch (error) {
logError('IgnoreAutoCommit: Error updating history:', error);
}
}, IGNORE_LIST_DEBOUNCE_MS);
});
logDebug('IgnoreAutoCommit: Auto-commit listener installed (debounce:', IGNORE_LIST_DEBOUNCE_MS, 'ms)');
}
/**
* Initialize History Feature
*
* Setup analyzer completion observers and create the history panel.
*/
/**
* Create floating version indicator in bottom-right corner
*/
function createVersionIndicator() {
const indicator = document.createElement('div');
indicator.id = 'script-version-indicator';
indicator.textContent = `v${SCRIPT_VERSION}`;
indicator.style.cssText = `
position: fixed;
bottom: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-family: monospace;
z-index: 999999;
pointer-events: none;
user-select: none;
`;
document.body.appendChild(indicator);
logDebug('Version indicator created:', SCRIPT_VERSION);
}
function initializeHistory() {
patchCommonDiscounts();
setupIgnoreListClearingFix();
setupIgnoreListAutoCommit();
setupAnalyzerCompletionObservers();
createVersionIndicator();
createClipboardPasteButton();
createOptimizerPanel();
createPowerLevelPanel();
createHistoryPanel();
// Auto-load latest history entry
try {
const history = loadHistory();
if (history.length > 0) {
const latestEntry = history[history.length - 1];
logDebug('History: Auto-loading latest entry from', latestEntry.timestamp);
// Fill deck list
const decklistTextarea = document.getElementById('decklist');
if (decklistTextarea && latestEntry.deckList) {
decklistTextarea.value = latestEntry.deckList;
}
// Fill commander names
const commander1Input = document.getElementById('commander-name-1');
const commander2Input = document.getElementById('commander-name-2');
const commander3Input = document.getElementById('commander-name-3');
if (commander1Input && latestEntry.commanders.primary) {
commander1Input.value = latestEntry.commanders.primary;
}
if (commander2Input && latestEntry.commanders.partner) {
commander2Input.value = latestEntry.commanders.partner;
}
if (commander3Input && latestEntry.commanders.companion) {
commander3Input.value = latestEntry.commanders.companion;
}
// Fill commander importance weights
const weight1Select = document.getElementById('cmdr1-weight');
const weight2Select = document.getElementById('cmdr2-weight');
const weight3Select = document.getElementById('cmdr3-weight');
if (weight1Select && latestEntry.commanders.primaryWeight) {
weight1Select.value = latestEntry.commanders.primaryWeight;
}
if (weight2Select && latestEntry.commanders.partnerWeight) {
weight2Select.value = latestEntry.commanders.partnerWeight;
}
if (weight3Select && latestEntry.commanders.companionWeight) {
weight3Select.value = latestEntry.commanders.companionWeight;
}
// Fill ignore/discount list (if saved in this entry and non-empty)
// If empty/undefined, clear the textarea so commonDiscounts can apply fresh
const ignoreTextarea = document.getElementById('ignore');
const hasSavedIgnores = latestEntry.ignoreList && latestEntry.ignoreList.trim().length > 0;
if (ignoreTextarea) {
if (hasSavedIgnores) {
ignoreTextarea.value = latestEntry.ignoreList;
logDebug('History: Restored ignore/discount list from latest entry');
} else {
ignoreTextarea.value = '';
logDebug('History: Cleared ignore/discount list (no saved ignores, defaults will apply)');
}
}
logDebug('History: Latest entry auto-loaded, triggering deck load...');
// Trigger deck load after a short delay to ensure DOM is ready
setTimeout(() => {
const loadButton = document.getElementById('deck-load-button');
if (loadButton && !loadButton.disabled) {
logDebug('History: Clicking load button to trigger analyzers');
loadButton.click();
// The existing auto-analyzer will handle triggering both analyzers
// after "Deck loaded!" appears. Duplicate detection prevents
// re-adding the same entry to history.
} else {
logWarn('History: Deck load button not available or disabled');
}
}, 500); // 500ms delay to ensure all elements are ready
} else {
logDebug('History: No history to auto-load');
}
} catch (error) {
logError('History: Error auto-loading latest entry:', error);
}
}
/**
* =================================================================
* HISTORY FEATURE - ANALYZER COMPLETION DETECTION
* =================================================================
*/
/**
* State Management for Analyzer Completion
*
* Track whether each analyzer has completed to trigger history capture
* only when both analyzers finish.
*/
let colorAnalyzerComplete = false;
let tapAnalyzerComplete = false;
/**
* Reset analyzer completion flags
*
* Call this when deck load or compute buttons are triggered to prepare
* for the next analysis cycle.
*/
function resetAnalyzerFlags() {
colorAnalyzerComplete = false;
tapAnalyzerComplete = false;
logDebug('History: Analyzer flags reset');
}
/**
* State flag to prevent duplicate captures
*/
let captureScheduled = false;
/**
* Check if both analyzers have completed and trigger history capture
*
* This function is called by both analyzer observers. It only triggers
* the history capture once, when both analyzers report completion.
*/
function checkBothAnalyzersComplete() {
if (colorAnalyzerComplete && tapAnalyzerComplete && !captureScheduled) {
logDebug('History: Both analyzers complete, scheduling capture...');
captureScheduled = true;
// Add a small delay to ensure DOM has fully updated with results
setTimeout(() => {
logDebug('History: Triggering capture after delay');
captureHistoryEntry();
// Reset flags for next analysis
resetAnalyzerFlags();
captureScheduled = false;
}, 500); // 500ms delay to ensure tables are populated
}
}
/**
* Setup MutationObservers for analyzer completion detection
*
* Watches both #color-analyzer-result-box and #tap-analyzer-result-box
* for changes. When content appears/changes, marks that analyzer as complete.
*/
function setupAnalyzerCompletionObservers() {
// Color Analyzer Observer
const colorResultBox = document.getElementById('color-analyzer-result-box');
if (colorResultBox) {
const colorObserver = new MutationObserver((mutations) => {
try {
// Check if there's actual content (not just empty or loading)
const resultElement = document.getElementById('color-analyzer-result');
if (resultElement && resultElement.innerText.trim().length > 0) {
if (!colorAnalyzerComplete) {
colorAnalyzerComplete = true;
logDebug('History: Color Analyzer completed');
checkBothAnalyzersComplete();
}
}
} catch (error) {
logError('History: Error in Color Analyzer observer:', error);
}
});
colorObserver.observe(colorResultBox, {
childList: true,
subtree: true,
characterData: true
});
logDebug('History: Color Analyzer observer initialized');
} else {
logWarn('History: color-analyzer-result-box not found');
}
// Tap Analyzer Observer
const tapResultBox = document.getElementById('tap-analyzer-result-box');
if (tapResultBox) {
const tapObserver = new MutationObserver((mutations) => {
try {
// Check if there's actual content (not just empty or loading)
const resultElement = document.getElementById('tap-analyzer-result');
if (resultElement && resultElement.innerText.trim().length > 0) {
if (!tapAnalyzerComplete) {
tapAnalyzerComplete = true;
logDebug('History: Tap Analyzer completed');
checkBothAnalyzersComplete();
}
}
} catch (error) {
logError('History: Error in Tap Analyzer observer:', error);
}
});
tapObserver.observe(tapResultBox, {
childList: true,
subtree: true,
characterData: true
});
logDebug('History: Tap Analyzer observer initialized');
} else {
logWarn('History: tap-analyzer-result-box not found');
}
}
/**
* =================================================================
* HISTORY FEATURE - STORAGE MODULE
* =================================================================
*/
const HISTORY_STORAGE_KEY = 'manabase-history';
const MAX_HISTORY_ENTRIES = 100;
/**
* Load history from localStorage
*
* @returns {Array} Array of history entries, or empty array if none exist
*/
function loadHistory() {
try {
const data = localStorage.getItem(HISTORY_STORAGE_KEY);
if (!data) {
logDebug('History: No existing history found');
return [];
}
const history = JSON.parse(data);
// Validate that we got an array
if (!Array.isArray(history)) {
logWarn('History: Corrupted history data (not an array), resetting');
localStorage.removeItem(HISTORY_STORAGE_KEY);
return [];
}
logDebug(`History: Loaded ${history.length} entries from storage`);
return history;
} catch (error) {
logError('History: Error loading history:', error);
logWarn('History: Clearing corrupted history data');
localStorage.removeItem(HISTORY_STORAGE_KEY);
return [];
}
}
/**
* Save history to localStorage with FIFO enforcement
*
* @param {Array} historyArray - Array of history entries to save
*/
function saveHistory(historyArray) {
try {
// Enforce FIFO limit: keep only the last MAX_HISTORY_ENTRIES
if (historyArray.length > MAX_HISTORY_ENTRIES) {
historyArray = historyArray.slice(-MAX_HISTORY_ENTRIES);
logDebug(`History: Trimmed to ${MAX_HISTORY_ENTRIES} entries (FIFO)`);
}
const jsonData = JSON.stringify(historyArray);
// Check storage size (rough estimate: 5-10MB typical localStorage limit)
const sizeKB = Math.round(jsonData.length / 1024);
logDebug(`History: Saving ${historyArray.length} entries (${sizeKB} KB)`);
localStorage.setItem(HISTORY_STORAGE_KEY, jsonData);
logDebug('History: Successfully saved to localStorage');
} catch (error) {
if (error.name === 'QuotaExceededError' || error.code === 22) {
logError('History: localStorage quota exceeded');
// Emergency trim: keep only 50 most recent entries
const trimmedHistory = historyArray.slice(-50);
try {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(trimmedHistory));
logWarn('History: Emergency trim to 50 entries successful');
} catch (e) {
logError('History: Failed to save even after emergency trim:', e);
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
} else {
logError('History: Error saving history:', error);
}
}
}
/**
* Add a new history entry
*
* @param {Object} entry - History entry object to add
*/
function addHistoryEntry(entry) {
try {
const history = loadHistory();
// Check if deck is same as most recent entry
if (history.length > 0) {
const lastEntry = history[history.length - 1];
if (lastEntry.deckList === entry.deckList) {
logDebug('History: Skipping duplicate entry (same deck as most recent)');
return;
}
}
history.push(entry);
saveHistory(history);
logDebug('History: Entry added successfully');
} catch (error) {
logError('History: Error adding history entry:', error);
}
}
/**
* Clear all history (executed after confirmation)
*/
function clearHistoryConfirmed() {
try {
logDebug('History: Clearing all history');
localStorage.removeItem(HISTORY_STORAGE_KEY);
logDebug('History: localStorage cleared');
renderHistoryPanel(); // Refresh UI to show empty state
logDebug('History: UI refreshed');
} catch (error) {
logError('History: Error clearing history:', error);
}
}
/**
* Delete a single history entry
*
* @param {number} index - Index of the entry to delete
*/
function deleteHistoryEntry(index) {
try {
logDebug('History: Deleting entry at index', index);
const history = loadHistory();
if (index < 0 || index >= history.length) {
logWarn('History: Invalid index for deletion:', index);
return;
}
// Remove the entry
history.splice(index, 1);
// Save updated history
saveHistory(history);
logDebug('History: Entry deleted, remaining entries:', history.length);
// Refresh UI
renderHistoryPanel();
} catch (error) {
logError('History: Error deleting entry:', error);
}
}
/**
* =================================================================
* HISTORY FEATURE - DIFF CALCULATION MODULE
* =================================================================
*/
/**
* Parse deck list text into a Map of card names to quantities
*
* @param {string} deckText - Raw deck list text
* @returns {Map<string, number>} Map of card names to quantities
*/
function parseDeckList(deckText) {
const cards = new Map();
if (!deckText || typeof deckText !== 'string') {
return cards;
}
const lines = deckText.trim().split('\n');
for (let line of lines) {
line = line.trim();
// Skip empty lines
if (!line) continue;
// Match pattern: "4 Sol Ring" or "1 Tropical Island"
// Also handles variations like "4x Sol Ring"
const match = line.match(/^(\d+)x?\s+(.+)$/i);
if (match) {
const qty = parseInt(match[1], 10);
const name = match[2].trim();
// Accumulate quantities if card appears multiple times
cards.set(name, (cards.get(name) || 0) + qty);
}
}
return cards;
}
/**
* Calculate the difference between two deck lists
*
* @param {string} oldDeck - Previous deck list text (or null for first entry)
* @param {string} newDeck - Current deck list text
* @returns {Object} Object with diff array and changeCount
*/
function calculateDiff(oldDeck, newDeck) {
// Handle first entry case (no previous deck)
if (!oldDeck) {
return { diff: [], changeCount: 0 };
}
const oldCards = parseDeckList(oldDeck);
const newCards = parseDeckList(newDeck);
const changes = [];
// Find removed or decreased cards
for (let [card, oldQty] of oldCards) {
const newQty = newCards.get(card) || 0;
if (newQty < oldQty) {
changes.push({ card, delta: newQty - oldQty }); // negative
}
}
// Find added or increased cards
for (let [card, newQty] of newCards) {
const oldQty = oldCards.get(card) || 0;
if (newQty > oldQty) {
changes.push({ card, delta: newQty - oldQty }); // positive
}
}
// Calculate total change count (sum of absolute deltas)
const changeCount = changes.reduce((sum, change) => sum + Math.abs(change.delta), 0);
logDebug(`History: Diff calculated - ${changes.length} card types changed, ${changeCount} total card changes`);
return { diff: changes, changeCount };
}
/**
* =================================================================
* HISTORY FEATURE - HISTORY CAPTURE ORCHESTRATOR
* =================================================================
*/
/**
* Capture a complete history entry
*
* This is the main orchestrator function that:
* 1. Extracts all data from the page
* 2. Gets the current deck list
* 3. Calculates diff vs previous entry
* 4. Assembles the complete entry object
* 5. Validates and saves the entry
* 6. Updates the UI (when implemented)
*
* @returns {boolean} True if entry was captured successfully, false otherwise
*/
function captureHistoryEntry() {
try {
logDebug('History: ========== Starting capture ==========');
// Get current deck list
const decklistTextarea = document.getElementById('decklist');
if (!decklistTextarea || !decklistTextarea.value.trim()) {
logWarn('History: No deck list found, skipping capture');
return false;
}
// Clean deck list (remove empty lines, trim)
const rawDeckList = decklistTextarea.value;
const cleanedLines = rawDeckList.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Limit to 110 lines
const deckList = cleanedLines.slice(0, 110).join('\n');
logDebug('History: Deck list has', cleanedLines.length, 'lines');
// Extract all metrics
logDebug('History: Extracting manabase metrics...');
const manabase = extractManabaseMetrics();
logDebug('History: Extracting commander metrics...');
const commanders = extractCommanderMetrics();
logDebug('History: Extracting summary...');
const summary = extractSummary();
logDebug('History: Extracting tap percent...');
const tapPercent = extractTapPercent();
logDebug('History: Extracting basic lands...');
const basicLands = extractBasicLands(deckList);
logDebug('History: Extracting ignore/discount list...');
const ignoreTextarea = document.getElementById('ignore');
const ignoreList = normalizeIgnoreList(ignoreTextarea ? ignoreTextarea.value : '');
// Log what we extracted
logDebug('History: Extracted data:', {
manabase,
commanders: {
primary: commanders.primary,
primaryWeight: commanders.primaryWeight,
primaryCastRate: commanders.primaryCastRate
},
summary: summary.substring(0, 50),
tapPercent,
basicLands
});
// Validate that we have some meaningful data
if (!manabase.castRate && !commanders.primary) {
logWarn('History: Insufficient data for entry (no metrics or commander), skipping');
return false;
}
// Calculate diff vs previous entry (skip if commander changed)
const history = loadHistory();
const previousEntry = history.length > 0 ? history[history.length - 1] : null;
let diff = [];
let changeCount = 0;
// Only calculate diff if we have a previous entry AND commander hasn't changed
if (previousEntry) {
const previousCommander = previousEntry.commanders.primary || '';
const currentCommander = commanders.primary || '';
if (previousCommander.toLowerCase() === currentCommander.toLowerCase()) {
// Same commander - calculate diff
const previousDeckList = previousEntry.deckList;
const diffResult = calculateDiff(previousDeckList, deckList);
diff = diffResult.diff;
changeCount = diffResult.changeCount;
logDebug('History: Calculating diff (same commander)');
} else {
// Different commander - skip diff
logDebug('History: Skipping diff (commander changed from', previousCommander, 'to', currentCommander, ')');
}
} else {
// No previous entry
logDebug('History: First entry, no diff to calculate');
}
// Get power level data if available
const powerLevel = getPowerLevelHistoryData();
// Assemble complete entry
const entry = {
timestamp: new Date().toISOString(),
deckList: deckList,
commanders: commanders,
manabase: manabase,
summary: summary,
tapPercent: tapPercent,
basicLands: basicLands,
ignoreList: ignoreList,
diff: diff,
changeCount: changeCount,
powerLevel: powerLevel // May be null if not calculated
};
// Log entry details
logDebug('History: Entry assembled:', {
timestamp: entry.timestamp,
commander: entry.commanders.primary,
manabaseMetrics: entry.manabase,
changes: entry.changeCount,
summaryHTML: entry.summary.substring(0, 100)
});
// Save entry
addHistoryEntry(entry);
// Update UI
renderHistoryPanel();
logDebug('History: Capture completed successfully');
return true;
} catch (error) {
logError('History: Error capturing history entry:', error);
return false;
}
}
/**
* =================================================================
* HISTORY FEATURE - UI MODULE
* =================================================================
*/
/**
* Create the history panel and insert it into the page
*
* The panel spans the full page width and is shown immediately on page load.
*/
function createHistoryPanel() {
try {
// Check if panel already exists
if (document.getElementById('history-panel')) {
logDebug('History: Panel already exists');
return;
}
// Find the main container to insert the panel after all analyzers
// Look for body or main content area
const body = document.body;
if (!body) {
logWarn('History: Body not found, cannot create history panel');
return;
}
// Create panel container (full width, not constrained by .analyzer class)
const panel = document.createElement('div');
panel.id = 'history-panel';
// Custom styling for full-width panel that clears floats
panel.style.cssText = `
width: 100%;
max-width: 1200px;
margin: 30px auto;
padding: 20px;
background-color: #EEEEEE;
border: 2px solid black;
border-radius: 10px;
box-sizing: border-box;
clear: both;
`;
// Create panel HTML structure
panel.innerHTML = `
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 15px;">
<h2 style="margin: 0; font-size: 20px; color: #333;">Analysis History</h2>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<input type="button" id="clear-history-btn" value="Clear"
style="width: auto; padding: 5px 10px; font-size: 12px; cursor: pointer;">
<input type="button" id="collapse-all-btn" value="Collapse"
style="width: auto; padding: 5px 10px; font-size: 12px; cursor: pointer;">
<input type="button" id="expand-all-btn" value="Expand"
style="width: auto; padding: 5px 10px; font-size: 12px; cursor: pointer;">
<input type="button" id="recalc-all-power-btn" value="Power Level All"
style="width: auto; padding: 5px 10px; font-size: 12px; cursor: pointer;">
</div>
</div>
<div id="history-list"></div>
`;
// Append to column A if it exists, otherwise body
const columnA = document.querySelector('.column-a');
const target = columnA || body;
target.appendChild(panel);
logDebug('History: Panel created and appended');
// Attach event listener to clear button with click-to-confirm pattern
const clearButton = document.getElementById('clear-history-btn');
if (clearButton) {
let confirmState = false;
const originalValue = clearButton.value;
clearButton.addEventListener('click', function (e) {
logDebug('History: Clear button clicked, confirmState:', confirmState);
if (!confirmState) {
// First click - ask for confirmation
confirmState = true;
clearButton.value = 'Click Again to Confirm';
clearButton.style.backgroundColor = '#e74c3c';
clearButton.style.color = 'white';
logDebug('History: Waiting for confirmation click');
// Reset after 3 seconds if not clicked again
setTimeout(() => {
if (confirmState) {
confirmState = false;
clearButton.value = originalValue;
clearButton.style.backgroundColor = '';
clearButton.style.color = '';
logDebug('History: Confirmation timeout, reset button');
}
}, 3000);
} else {
// Second click - execute clear
logDebug('History: Confirmed, clearing history');
confirmState = false;
clearButton.value = originalValue;
clearButton.style.backgroundColor = '';
clearButton.style.color = '';
clearHistoryConfirmed();
}
});
logDebug('History: Clear button event listener attached');
} else {
logWarn('History: Clear button not found, event listener not attached');
}
// Attach event listener to expand all button
const expandAllButton = document.getElementById('expand-all-btn');
if (expandAllButton) {
expandAllButton.addEventListener('click', function (e) {
logDebug('History: Expand all clicked');
const allHeaders = document.querySelectorAll('.history-entry-header');
allHeaders.forEach(header => {
const details = header.nextElementSibling;
const icon = header.querySelector('.expand-icon');
if (details && icon && details.style.display === 'none') {
details.style.display = 'block';
icon.textContent = '▼';
}
});
});
logDebug('History: Expand all button event listener attached');
} else {
logWarn('History: Expand all button not found');
}
// Attach event listener to collapse all button
const collapseAllButton = document.getElementById('collapse-all-btn');
if (collapseAllButton) {
collapseAllButton.addEventListener('click', function (e) {
logDebug('History: Collapse all clicked');
const allHeaders = document.querySelectorAll('.history-entry-header');
allHeaders.forEach(header => {
const details = header.nextElementSibling;
const icon = header.querySelector('.expand-icon');
if (details && icon && details.style.display !== 'none') {
details.style.display = 'none';
icon.textContent = '▶';
}
});
});
logDebug('History: Collapse all button event listener attached');
} else {
logWarn('History: Collapse all button not found');
}
// Attach event listener to recalc all power levels button
const recalcAllButton = document.getElementById('recalc-all-power-btn');
if (recalcAllButton) {
recalcAllButton.addEventListener('click', function (e) {
recalcAllPowerLevels(recalcAllButton);
});
logDebug('History: Recalc all button event listener attached');
}
// Initial render - show history immediately
renderHistoryPanel();
} catch (error) {
logError('History: Error creating history panel:', error);
}
}
/**
* Render the history panel with all entries
*
* Displays entries in reverse chronological order (newest first).
* Shows a message if no history exists yet.
*/
function renderHistoryPanel() {
try {
const listContainer = document.getElementById('history-list');
if (!listContainer) {
logWarn('History: history-list container not found');
return;
}
// Capture expanded state before rebuilding
const expandedIndices = new Set();
document.querySelectorAll('.history-entry-details').forEach(details => {
if (details.style.display !== 'none') {
const entry = details.closest('.history-entry');
if (entry && entry.dataset.index) {
expandedIndices.add(entry.dataset.index);
}
}
});
const history = loadHistory();
if (history.length === 0) {
listContainer.innerHTML = '<p style="color: #666; font-style: italic; padding: 10px;">No history entries yet. Load and analyze a deck to get started.</p>';
logDebug('History: No entries to display');
return;
}
// Render entries in reverse order (newest first)
const entriesHTML = history.slice().reverse().map((entry, idx) => {
const actualIndex = history.length - 1 - idx;
return renderHistoryEntry(entry, actualIndex);
}).join('');
listContainer.innerHTML = entriesHTML;
// Restore expanded state
expandedIndices.forEach(index => {
const entry = document.querySelector(`.history-entry[data-index="${index}"]`);
if (entry) {
const details = entry.querySelector('.history-entry-details');
const icon = entry.querySelector('.expand-icon');
if (details) details.style.display = 'block';
if (icon) icon.textContent = '▼';
}
});
// Fix summary HTML rendering (template literals may escape)
// Re-insert summary content as proper HTML for each entry
document.querySelectorAll('.history-summary').forEach((summaryDiv, idx) => {
const entryIndex = parseInt(summaryDiv.dataset.index);
const entry = history[entryIndex];
if (entry && entry.summary) {
summaryDiv.innerHTML = entry.summary;
}
});
// Attach event listeners
attachHistoryEventListeners();
logDebug(`History: Rendered ${history.length} entries`);
} catch (error) {
logError('History: Error rendering history panel:', error);
}
}
/**
* Render a single history entry
*
* @param {Object} entry - History entry object
* @param {number} index - Index in the history array
* @returns {string} HTML string for the entry
*/
function renderHistoryEntry(entry, index) {
try {
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleString();
// Format commander display
let commanderDisplay = entry.commanders.primary || 'Unknown';
if (entry.commanders.partner) {
commanderDisplay += ' + ' + entry.commanders.partner;
}
if (entry.commanders.companion) {
commanderDisplay += ' (' + entry.commanders.companion + ')';
}
// Format diff text in a grid for better use of space
let diffText = '<span style="color: #666; font-style: italic;">No changes</span>';
if (entry.diff && entry.diff.length > 0) {
// Split into additions and removals for cleaner display
const additions = entry.diff.filter(d => d.delta > 0);
const removals = entry.diff.filter(d => d.delta < 0);
let diffHTML = '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">';
if (additions.length > 0) {
diffHTML += '<div><strong style="color: green;">Added:</strong><br>';
diffHTML += additions.map(d =>
`<span style="color: green;">+${d.delta} ${d.card}</span>`
).join('<br>');
diffHTML += '</div>';
}
if (removals.length > 0) {
diffHTML += '<div><strong style="color: red;">Removed:</strong><br>';
diffHTML += removals.map(d =>
`<span style="color: red;">${d.delta} ${d.card}</span>`
).join('<br>');
diffHTML += '</div>';
}
diffHTML += '</div>';
diffText = diffHTML;
}
// Format power level display for header
const plDisplay = entry.powerLevel
? entry.powerLevel.powerLevel.toFixed(1)
: '-';
// Format power level section for details
let powerLevelSection = '';
if (entry.powerLevel) {
const pl = entry.powerLevel;
const topCardsHtml = pl.topCards && pl.topCards.length > 0
? pl.topCards.map(c => `<span style="color: #198754;">${c.name} (${c.impact.toFixed(1)})</span>`).join(', ')
: 'N/A';
const bottomCardsHtml = pl.bottomCards && pl.bottomCards.length > 0
? pl.bottomCards.map(c => `<span style="color: #dc3545;">${c.name} (${c.impact.toFixed(1)})</span>`).join(', ')
: 'N/A';
powerLevelSection = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 14px; color: #333;">Power Level</h4>
<input type="button" class="recalc-power-button" data-index="${index}" value="Recalc"
style="cursor: pointer; padding: 4px 12px; font-size: 11px; background: #6f42c1; color: white; border: none; border-radius: 4px;">
</div>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 10px; text-align: center;">
<div style="background: #f8f9fa; padding: 8px; border-radius: 4px; cursor: help;" title="Traditional 1-10 power level from scoring curve">
<div style="font-size: 18px; font-weight: bold; color: #6f42c1;">${pl.powerLevel.toFixed(1)}</div>
<div style="font-size: 10px; color: #666;">Power</div>
</div>
<div style="background: #f8f9fa; padding: 8px; border-radius: 4px; cursor: help;" title="Mana needed to access 65% of deck's non-land impact">
<div style="font-size: 18px; font-weight: bold; color: #fd7e14;">${pl.tippingPoint}</div>
<div style="font-size: 10px; color: #666;">Tipping</div>
</div>
<div style="background: #f8f9fa; padding: 8px; border-radius: 4px; cursor: help;" title="Based on avg CMC and tipping point. Higher = faster deck">
<div style="font-size: 18px; font-weight: bold; color: #198754;">${pl.efficiency.toFixed(1)}</div>
<div style="font-size: 10px; color: #666;">Efficiency</div>
</div>
<div style="background: #f8f9fa; padding: 8px; border-radius: 4px; cursor: help;" title="Sum of card impacts based on price + EDHREC popularity">
<div style="font-size: 18px; font-weight: bold; color: #dc3545;">${pl.totalImpact.toFixed(0)}</div>
<div style="font-size: 10px; color: #666;">Impact</div>
</div>
<div style="background: #f8f9fa; padding: 8px; border-radius: 4px; cursor: help;" title="Impact x Efficiency. Linear competitiveness measure (max 1000)">
<div style="font-size: 18px; font-weight: bold; color: #0d6efd;">${pl.score}</div>
<div style="font-size: 10px; color: #666;">Score</div>
</div>
</div>
<p style="margin: 5px 0; font-size: 12px;">
<strong>Top 5:</strong> ${topCardsHtml}<br>
<strong>Bottom 5:</strong> ${bottomCardsHtml}${pl.gamechangerCards && pl.gamechangerCards.length > 0 ? `<br>
<strong>Game Changers:</strong> <span style="color: #6c757d;">${pl.gamechangerCards.join(', ')}</span>` : ''}
</p>
<p style="margin: 5px 0; font-size: 11px; color: #888;">Calculated: ${new Date(pl.calculatedAt).toLocaleString()}</p>
</div>
`;
} else {
powerLevelSection = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 14px; color: #333;">Power Level</h4>
<input type="button" class="recalc-power-button" data-index="${index}" value="Calculate"
style="cursor: pointer; padding: 4px 12px; font-size: 11px; background: #6f42c1; color: white; border: none; border-radius: 4px;">
</div>
<p style="margin: 5px 0; font-size: 13px; color: #888; font-style: italic;">
Not yet calculated
</p>
</div>
`;
}
// Build HTML with improved layout for full width
return `
<div class="history-entry" data-index="${index}"
style="border: 1px solid #ccc; border-radius: 5px; padding: 15px; margin-bottom: 12px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<div class="history-entry-header" style="cursor: pointer; user-select: none;">
<div style="display: grid; grid-template-columns: auto 1fr auto auto auto; gap: 15px; align-items: center;">
<span class="expand-icon" style="font-size: 14px;">▶</span>
<div>
<strong style="font-size: 15px;">${formattedDate}</strong><br>
<span style="font-size: 13px; color: #555;">Commander: ${commanderDisplay}</span>
</div>
<div style="text-align: center; min-width: 50px;">
<span style="font-size: 16px; font-weight: bold; color: #6f42c1;">${plDisplay}</span>
</div>
<div style="text-align: right;">
<span style="font-size: 13px; color: #555;">Tap: ${entry.tapPercent || '0.0'}%, AD: ${entry.manabase.avgDelay || 'N/A'}, CR: ${entry.manabase.castRate || 'N/A'}</span><br>
<span style="font-size: 12px;">${formatBasicLandsShort(entry.basicLands)}</span>
</div>
<div style="text-align: right;">
<span style="font-size: 13px; color: ${entry.changeCount > 0 ? '#e67e22' : '#999'}; font-weight: ${entry.changeCount > 0 ? 'bold' : 'normal'};">
${entry.changeCount || 0} cards changed
</span>
</div>
</div>
</div>
<div class="history-entry-details" style="display: none; margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
${powerLevelSection}
<div style="margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Manabase Performance</h4>
<p style="margin: 5px 0; font-size: 13px; line-height: 1.4;">
Current Manabase: <strong>${entry.manabase.castRate || 'N/A'}</strong> cast rate / <strong>${entry.manabase.avgDelay || 'N/A'}</strong> average delay<br>
Tap Statistics: <strong>${entry.tapPercent || '0.0'}%</strong> of lands enter tapped<br>
Basic Lands: <strong>${formatBasicLandsFull(entry.basicLands)}</strong>
</p>
</div>
<div style="margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Commander Metrics</h4>
<p style="margin: 5px 0; font-size: 13px;">${entry.commanders.primary}: <strong>${entry.commanders.primaryCastRate || 'N/A'}</strong> cast rate / <strong>${entry.commanders.primaryAvgDelay || 'N/A'}</strong> average delay (weight: <strong>${entry.commanders.primaryWeight || 'N/A'}</strong>)</p>
${entry.commanders.partner ? `<p style="margin: 5px 0; font-size: 13px;">${entry.commanders.partner}: <strong>${entry.commanders.partnerCastRate || 'N/A'}</strong> cast rate / <strong>${entry.commanders.partnerAvgDelay || 'N/A'}</strong> average delay (weight: <strong>${entry.commanders.partnerWeight || 'N/A'}</strong>)</p>` : ''}
${entry.commanders.companion ? `<p style="margin: 5px 0; font-size: 13px;">${entry.commanders.companion}: <strong>${entry.commanders.companionCastRate || 'N/A'}</strong> cast rate / <strong>${entry.commanders.companionAvgDelay || 'N/A'}</strong> average delay (weight: <strong>${entry.commanders.companionWeight || 'N/A'}</strong>)</p>` : ''}
</div>
<div style="margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Summary</h4>
<div class="history-summary" data-index="${index}" style="margin: 5px 0; font-size: 13px; line-height: 1.6;">${entry.summary || 'N/A'}</div>
</div>
${entry.ignoreList ? `
<div style="margin-top: 15px;">
<h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Ignores/Discounts</h4>
<pre style="margin: 5px 0; font-size: 12px; background: #f5f5f5; padding: 8px; border-radius: 4px; white-space: pre-wrap; font-family: monospace;">${entry.ignoreList}</pre>
</div>
` : ''}
<div style="margin-top: 15px;">
<h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Deck Changes</h4>
${diffText}
</div>
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
<input type="button" class="delete-entry-button" data-index="${index}" value="Delete Entry"
style="cursor: pointer; padding: 8px 20px; font-size: 14px; background: #e74c3c; color: white; border: none; border-radius: 4px; font-weight: bold;">
<input type="button" class="restore-button" data-index="${index}" value="Restore This Entry"
style="cursor: pointer; padding: 8px 20px; font-size: 14px; background: #3498db; color: white; border: none; border-radius: 4px; font-weight: bold;">
</div>
</div>
</div>
`;
} catch (error) {
logError('History: Error rendering entry:', error);
return '<div style="color: red;">Error rendering entry</div>';
}
}
/**
* Attach event listeners to history entries
*
* Sets up expand/collapse functionality and restore button handlers.
*/
function attachHistoryEventListeners() {
try {
// Expand/collapse entries
document.querySelectorAll('.history-entry-header').forEach(header => {
header.addEventListener('click', function () {
const details = this.nextElementSibling;
const icon = this.querySelector('.expand-icon');
if (details && icon) {
if (details.style.display === 'none') {
details.style.display = 'block';
icon.textContent = '▼';
} else {
details.style.display = 'none';
icon.textContent = '▶';
}
}
});
});
// Restore buttons
document.querySelectorAll('.restore-button').forEach(button => {
button.addEventListener('click', function (e) {
e.stopPropagation(); // Prevent header click
const index = parseInt(this.dataset.index);
restoreHistoryEntry(index);
});
});
// Delete entry buttons
document.querySelectorAll('.delete-entry-button').forEach(button => {
button.addEventListener('click', function (e) {
e.stopPropagation(); // Prevent header click
const index = parseInt(this.dataset.index);
// Confirm deletion
const entry = loadHistory()[index];
if (entry) {
const entryDate = new Date(entry.timestamp).toLocaleString();
const entryCommander = entry.commanders.primary || 'Unknown';
// Use click-to-confirm pattern (same as Clear History)
if (this.dataset.confirmState === 'true') {
// Second click - delete
deleteHistoryEntry(index);
this.dataset.confirmState = 'false';
} else {
// First click - ask for confirmation
this.dataset.confirmState = 'true';
this.value = 'Click Again to Confirm';
this.style.backgroundColor = '#c0392b';
// Reset after 3 seconds
setTimeout(() => {
this.dataset.confirmState = 'false';
this.value = 'Delete Entry';
this.style.backgroundColor = '#e74c3c';
}, 3000);
}
}
});
});
// Recalc power level buttons
document.querySelectorAll('.recalc-power-button').forEach(button => {
button.addEventListener('click', function (e) {
e.stopPropagation(); // Prevent header click
const index = parseInt(this.dataset.index);
recalcPowerLevelForEntry(index, this);
});
});
logDebug('History: Event listeners attached');
} catch (error) {
logError('History: Error attaching event listeners:', error);
}
}
/**
* Recalculate power level for a specific history entry
*/
function recalcPowerLevelForEntry(index, buttonElement) {
const history = loadHistory();
const entry = history[index];
if (!entry || !entry.deckList) {
logError('PowerLevel: Cannot recalc - entry not found or no deck list');
return;
}
// Disable button and show progress
if (buttonElement) {
buttonElement.disabled = true;
buttonElement.value = 'Calculating...';
}
// Parse deck list
const cardNames = [];
const cardQuantities = {};
const commander1 = entry.commanders.primary || '';
const commander2 = entry.commanders.partner || '';
entry.deckList.split('\n').forEach(line => {
line = line.trim();
if (!line) return;
const match = line.match(/^(\d+)\s+(.+)$/) || line.match(/^(.+)$/);
if (match) {
const qty = match[2] ? parseInt(match[1]) : 1;
const name = (match[2] || match[1]).split(' (')[0].split(' //')[0].trim();
if (name) {
if (!cardQuantities[name]) {
cardNames.push(name);
cardQuantities[name] = 0;
}
cardQuantities[name] += qty;
}
}
});
if (cardNames.length === 0) {
logError('PowerLevel: No cards found in deck list');
if (buttonElement) {
buttonElement.disabled = false;
buttonElement.value = 'Calculate Now';
}
return;
}
// Fetch from API
const cardListHeader = cardNames.join('~');
GM_xmlhttpRequest({
method: 'GET',
url: 'https://getcards-4zjvieuafa-uc.a.run.app',
headers: { 'card-list': cardListHeader },
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data.data && data.data.length > 0) {
// Calculate power level (reuse same logic)
const powerLevelData = calculatePowerLevelFromApiData(data.data, cardQuantities, commander1, commander2);
// Update history entry
const freshHistory = loadHistory();
if (freshHistory[index]) {
freshHistory[index].powerLevel = powerLevelData;
saveHistory(freshHistory);
renderHistoryPanel();
logInfo('PowerLevel: Recalculated for entry', index);
}
}
} catch (e) {
logError('PowerLevel: Error recalculating', e);
} finally {
if (buttonElement) {
buttonElement.disabled = false;
buttonElement.value = 'Recalc Power Level';
}
}
},
onerror: function(error) {
logError('PowerLevel: API error', error);
if (buttonElement) {
buttonElement.disabled = false;
buttonElement.value = 'Calculate Now';
}
}
});
}
/**
* Calculate power level data from API response (for history recalc)
*/
/**
* Recalculate power levels for all history entries
*/
async function recalcAllPowerLevels(buttonElement) {
const history = loadHistory();
const entriesToProcess = history.filter(entry => entry.deckList);
if (entriesToProcess.length === 0) {
logInfo('PowerLevel: No entries to recalculate');
return;
}
buttonElement.disabled = true;
const originalText = buttonElement.value;
let processed = 0;
logInfo(`PowerLevel: Recalculating ${entriesToProcess.length} entries...`);
// Process entries sequentially with delay to avoid rate limiting
for (let i = 0; i < history.length; i++) {
const entry = history[i];
if (!entry.deckList) continue;
buttonElement.value = `⏳ ${processed + 1}/${entriesToProcess.length}...`;
// Parse deck list
const cardNames = [];
const cardQuantities = {};
const commander1 = entry.commanders?.primary || '';
const commander2 = entry.commanders?.partner || '';
entry.deckList.split('\n').forEach(line => {
line = line.trim();
if (!line) return;
const match = line.match(/^(\d+)\s+(.+)$/) || line.match(/^(.+)$/);
if (match) {
const qty = match[2] ? parseInt(match[1]) : 1;
const name = (match[2] || match[1]).split(' (')[0].split(' //')[0].trim();
if (name) {
if (!cardQuantities[name]) {
cardNames.push(name);
cardQuantities[name] = 0;
}
cardQuantities[name] += qty;
}
}
});
if (cardNames.length === 0) {
processed++;
continue;
}
// Fetch from API (wrapped in promise for async/await)
try {
const powerLevelData = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://getcards-4zjvieuafa-uc.a.run.app',
headers: { 'card-list': cardNames.join('~') },
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data.data && data.data.length > 0) {
resolve(calculatePowerLevelFromApiData(data.data, cardQuantities, commander1, commander2));
} else {
resolve(null);
}
} catch (e) {
reject(e);
}
},
onerror: reject
});
});
if (powerLevelData) {
history[i].powerLevel = powerLevelData;
}
} catch (e) {
logError(`PowerLevel: Error processing entry ${i}`, e);
}
processed++;
// Small delay between requests to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
}
// Save updated history
saveHistory(history);
renderHistoryPanel();
buttonElement.disabled = false;
buttonElement.value = originalText;
logInfo(`PowerLevel: Recalculated ${processed} entries`);
}
function calculatePowerLevelFromApiData(apiData, cardQuantities, commander1, commander2) {
const cardData = [];
let totalImpact = 0;
let totalCmc = 0;
let landCount = 0;
let nonLandCount = 0;
const gamechangerCards = [];
const cmcImpactMap = new Map();
apiData.forEach(card => {
const name = card.name;
const quantity = cardQuantities[name] || 1;
const price = parseFloat(card.price) || 0;
const edhrec_rank = card.edhrec_rank;
const cmc = card.cmc || 0;
const isLand = card.type_line && card.type_line.split(' // ')[0].split(' — ')[0].includes('Land');
const isModalDFC = card.layout === 'modal_dfc';
const isBasicLand = BASIC_LANDS.includes(name);
const isCommander = (commander1 && name.toLowerCase().includes(commander1.toLowerCase())) ||
(commander2 && name.toLowerCase().includes(commander2.toLowerCase()));
const gamechanger = card.gamechanger || false;
const override = CARD_OVERRIDES[name] || {};
const effectivePrice = override.price ?? price;
const effectiveCmc = override.cmc ?? cmc;
const priceScore = calculatePriceScore(effectivePrice);
const popScore = calculatePopularityScore(edhrec_rank);
let impact = (priceScore + popScore) * quantity;
if (override.impact) impact *= override.impact;
if (isCommander && override.commanderImpact) impact *= override.commanderImpact;
if (isLand || isModalDFC) impact *= POWER_LEVEL_CONFIG.landFactor;
if (isBasicLand) impact = POWER_LEVEL_CONFIG.basicLandImpact * quantity;
totalImpact += impact;
if (isLand || isModalDFC) {
landCount += quantity;
} else {
totalCmc += effectiveCmc * quantity;
nonLandCount += quantity;
const current = cmcImpactMap.get(effectiveCmc) || 0;
cmcImpactMap.set(effectiveCmc, current + impact);
}
if (gamechanger) gamechangerCards.push(name);
cardData.push({
name,
impactPerCard: impact / quantity,
isLand,
});
});
const avgCmc = nonLandCount > 0 ? totalCmc / nonLandCount : 0;
// Tipping point
const sortedCmcs = Array.from(cmcImpactMap.keys()).sort((a, b) => a - b);
let totalNonLandImpact = 0;
cardData.forEach(c => { if (!c.isLand) totalNonLandImpact += c.impactPerCard; });
const threshold = totalNonLandImpact * POWER_LEVEL_CONFIG.tippingPointThreshold;
let cumulative = 0;
let tippingPoint = sortedCmcs[sortedCmcs.length - 1] || 0;
for (const cmc of sortedCmcs) {
cumulative += cmcImpactMap.get(cmc) || 0;
if (cumulative >= threshold) {
tippingPoint = cmc;
break;
}
}
// Efficiency
const combined = (avgCmc + tippingPoint) / 2;
const ratio = Math.max(0, Math.min(1, (POWER_LEVEL_CONFIG.cmcCeiling - combined) / (POWER_LEVEL_CONFIG.cmcCeiling - POWER_LEVEL_CONFIG.cmcFloor)));
const [minEff, maxEff] = POWER_LEVEL_CONFIG.efficiencyLimits;
const efficiencyMultiplier = minEff + (maxEff - minEff) * ratio;
const efficiency = ratio * 10;
const score = totalImpact * efficiencyMultiplier;
const powerLevel = interpolateCurve(score, POWER_LEVEL_CONFIG.powerCurve);
// Top/bottom 5 (excluding game changers)
const nonLandNonGCCards = cardData.filter(c => !c.isLand && !c.gamechanger).sort((a, b) => b.impactPerCard - a.impactPerCard);
const topCards = nonLandNonGCCards.slice(0, 5).map(c => ({ name: c.name, impact: Math.round(c.impactPerCard * 100) / 100 }));
const bottomCards = nonLandNonGCCards.slice(-5).reverse().map(c => ({ name: c.name, impact: Math.round(c.impactPerCard * 100) / 100 }));
return {
powerLevel: Math.round(powerLevel * 100) / 100,
score: Math.round(score),
efficiency: Math.round(efficiency * 100) / 100,
tippingPoint,
totalImpact: Math.round(totalImpact * 100) / 100,
avgCmc: Math.round(avgCmc * 100) / 100,
landCount,
nonLandCount,
gamechangerCards,
topCards,
bottomCards,
calculatedAt: new Date().toISOString(),
};
}
/**
* Restore a history entry to the deck input and re-run analyzers
*
* @param {number} index - Index of the entry in the history array
*/
function restoreHistoryEntry(index) {
try {
const history = loadHistory();
const entry = history[index];
if (!entry) {
logWarn('History: Entry not found at index', index);
return;
}
logDebug('History: Restoring entry from', entry.timestamp);
// Fill deck list
const decklistTextarea = document.getElementById('decklist');
if (decklistTextarea) {
decklistTextarea.value = entry.deckList;
}
// Fill commander names
const commander1Input = document.getElementById('commander-name-1');
const commander2Input = document.getElementById('commander-name-2');
const commander3Input = document.getElementById('commander-name-3');
if (commander1Input) {
commander1Input.value = entry.commanders.primary || '';
}
if (commander2Input) {
commander2Input.value = entry.commanders.partner || '';
}
if (commander3Input) {
commander3Input.value = entry.commanders.companion || '';
}
// Fill commander importance weights
const weight1Select = document.getElementById('cmdr1-weight');
const weight2Select = document.getElementById('cmdr2-weight');
const weight3Select = document.getElementById('cmdr3-weight');
if (weight1Select && entry.commanders.primaryWeight) {
weight1Select.value = entry.commanders.primaryWeight;
}
if (weight2Select && entry.commanders.partnerWeight) {
weight2Select.value = entry.commanders.partnerWeight;
}
if (weight3Select && entry.commanders.companionWeight) {
weight3Select.value = entry.commanders.companionWeight;
}
// Fill ignore/discount list (if saved in this entry and non-empty)
// If empty/undefined, clear the textarea so commonDiscounts can apply fresh
const ignoreTextarea = document.getElementById('ignore');
const hasSavedIgnores = entry.ignoreList && entry.ignoreList.trim().length > 0;
if (ignoreTextarea) {
if (hasSavedIgnores) {
ignoreTextarea.value = entry.ignoreList;
logDebug('History: Restored ignore/discount list');
} else {
ignoreTextarea.value = '';
logDebug('History: Cleared ignore/discount list (no saved ignores, defaults will apply)');
}
}
// Scroll iframe to top to show what was restored
// Note: Cannot scroll parent window due to cross-origin restrictions
// (parent is salubrioussnail.com, iframe is ianrh125.github.io)
window.scrollTo({
top: 0,
behavior: 'smooth'
});
logDebug('History: Scrolled iframe to top');
// Scroll column A (deck+history column) to top
const columnA = document.querySelector('.column-a');
if (columnA) {
columnA.scrollTo({
top: 0,
behavior: 'smooth'
});
logDebug('History: Scrolled column A to top');
}
// Trigger deck load
const loadButton = document.getElementById('deck-load-button');
if (loadButton && !loadButton.disabled) {
logDebug('History: Triggering deck load...');
loadButton.click();
// The existing auto-analyzer will handle triggering both analyzers
// after "Deck loaded!" appears
} else {
logWarn('History: Deck load button not available or disabled');
}
} catch (error) {
logError('History: Error restoring entry:', error);
}
}
/**
* =================================================================
* HISTORY FEATURE - DATA EXTRACTION MODULE
* =================================================================
*/
/**
* Extract manabase metrics from the Color Analyzer results table
*
* @returns {Object} Object containing castRate and avgDelay, or empty strings if not found
*/
function extractManabaseMetrics() {
try {
// Actual table ID from website is 'color-test-table-0'
const table = document.getElementById('color-test-table-0');
if (!table) {
logWarn('History: color-test-table-0 not found');
return { castRate: '', avgDelay: '' };
}
logDebug('History: color-test-table-0 found with', table.rows.length, 'rows');
// Find the row with "Current Manabase"
let manabaseRow = null;
for (let i = 0; i < table.rows.length; i++) {
const row = table.rows[i];
if (row.cells[0] && row.cells[0].innerText.trim().toLowerCase().includes('current manabase')) {
manabaseRow = row;
logDebug('History: Found manabase row at index', i);
break;
}
}
if (!manabaseRow) {
logWarn('History: Could not find "Current Manabase" row');
// Try row 1 as fallback (row 0 is header)
if (table.rows.length > 1) {
manabaseRow = table.rows[1];
logDebug('History: Using fallback row 1');
} else {
return { castRate: '', avgDelay: '' };
}
}
if (!manabaseRow.cells || manabaseRow.cells.length < 3) {
logWarn('History: Insufficient cells in manabase row, found', manabaseRow.cells.length);
return { castRate: '', avgDelay: '' };
}
const castRate = manabaseRow.cells[1]?.innerText?.trim() || '';
const avgDelay = manabaseRow.cells[2]?.innerText?.trim() || '';
logDebug('History: Extracted manabase metrics:', { castRate, avgDelay });
return { castRate, avgDelay };
} catch (error) {
logError('History: Error extracting manabase metrics:', error);
return { castRate: '', avgDelay: '' };
}
}
/**
* Extract commander importance weights from dropdowns
*
* @returns {Object} Object containing weight values for each commander slot
*/
function extractCommanderWeights() {
try {
const weight1 = document.getElementById('cmdr1-weight');
const weight2 = document.getElementById('cmdr2-weight');
const weight3 = document.getElementById('cmdr3-weight');
return {
primaryWeight: weight1 ? weight1.value : '',
partnerWeight: weight2 ? weight2.value : '',
companionWeight: weight3 ? weight3.value : ''
};
} catch (error) {
logError('History: Error extracting commander weights:', error);
return { primaryWeight: '', partnerWeight: '', companionWeight: '' };
}
}
/**
* Extract commander metrics from the Color Analyzer card table
*
* @returns {Object} Object containing commander names, weights, cast rates, and delays
*/
function extractCommanderMetrics() {
try {
// Get commander names
const commander1Input = document.getElementById('commander-name-1');
const commander2Input = document.getElementById('commander-name-2');
const commander3Input = document.getElementById('commander-name-3');
const commanderName1 = commander1Input ? commander1Input.value.trim() : '';
const commanderName2 = commander2Input ? commander2Input.value.trim() : '';
const commanderName3 = commander3Input ? commander3Input.value.trim() : '';
logDebug('History: Commander names:', { commanderName1, commanderName2, commanderName3 });
// Get commander weights
const weights = extractCommanderWeights();
// Initialize commander metrics object
const commanders = {
primary: commanderName1,
primaryWeight: weights.primaryWeight,
partner: commanderName2,
partnerWeight: weights.partnerWeight,
companion: commanderName3,
companionWeight: weights.companionWeight,
primaryCastRate: '',
primaryAvgDelay: '',
partnerCastRate: '',
partnerAvgDelay: '',
companionCastRate: '',
companionAvgDelay: ''
};
// Extract cast rates and delays from card table
// Actual table ID from website is 'color-test-table-1'
const cardTable = document.getElementById('color-test-table-1');
if (!cardTable) {
logWarn('History: color-test-table-1 not found');
return commanders;
}
logDebug('History: color-test-table-1 found with', cardTable.rows.length, 'rows');
for (let i = 0; i < cardTable.rows.length; i++) {
const row = cardTable.rows[i];
if (!row.cells || row.cells.length < 4) {
logDebug('History: Skipping row', i, 'insufficient cells');
continue;
}
const cardName = row.cells[0]?.innerText?.trim() || '';
// Debug log for first few rows
if (i < 5) {
logDebug('History: Row', i, 'card:', cardName, 'cells:', row.cells.length);
}
// Helper function to match commander names (handles truncated names like "Karlov of the Ghost...")
const matchesCommander = (fullName, tableName) => {
const fullLower = fullName.toLowerCase();
const tableLower = tableName.toLowerCase();
// Exact match
if (fullLower === tableLower) return true;
// Table name includes full name (normal case)
if (tableLower.includes(fullLower)) return true;
// Table name is truncated (ends with "..."), check if full name starts with truncated part
if (tableLower.endsWith('...')) {
const truncatedPart = tableLower.replace(/\.\.\.+$/, '').trim();
if (fullLower.startsWith(truncatedPart)) return true;
}
return false;
};
// Match primary commander
if (commanderName1 && matchesCommander(commanderName1, cardName)) {
commanders.primaryCastRate = row.cells[2]?.innerText?.trim() || '';
commanders.primaryAvgDelay = row.cells[3]?.innerText?.trim() || '';
logDebug('History: Found primary commander metrics:', commanders.primaryCastRate, commanders.primaryAvgDelay);
}
// Match partner
if (commanderName2 && matchesCommander(commanderName2, cardName)) {
commanders.partnerCastRate = row.cells[2]?.innerText?.trim() || '';
commanders.partnerAvgDelay = row.cells[3]?.innerText?.trim() || '';
logDebug('History: Found partner metrics:', commanders.partnerCastRate, commanders.partnerAvgDelay);
}
// Match companion
if (commanderName3 && matchesCommander(commanderName3, cardName)) {
commanders.companionCastRate = row.cells[2]?.innerText?.trim() || '';
commanders.companionAvgDelay = row.cells[3]?.innerText?.trim() || '';
logDebug('History: Found companion metrics:', commanders.companionCastRate, commanders.companionAvgDelay);
}
}
logDebug('History: Final commander metrics:', commanders);
return commanders;
} catch (error) {
logError('History: Error extracting commander metrics:', error);
return {
primary: '', primaryWeight: '', partner: '', partnerWeight: '',
companion: '', companionWeight: '', primaryCastRate: '',
primaryAvgDelay: '', partnerCastRate: '', partnerAvgDelay: '',
companionCastRate: '', companionAvgDelay: ''
};
}
}
/**
* Extract summary text from Color Analyzer results
*
* @returns {string} Summary text or empty string if not found
*/
function extractSummary() {
try {
const resultElement = document.getElementById('color-analyzer-result');
if (!resultElement) {
logWarn('History: color-analyzer-result element not found');
return '';
}
const resultHTML = resultElement.innerHTML;
logDebug('History: color-analyzer-result HTML length:', resultHTML.length);
// Extract everything after "<strong>Summary:</strong><br>"
// Keep the HTML formatting (especially <strong> tags)
const summaryMatch = resultHTML.match(/<strong>Summary:<\/strong><br\s*\/?>(.*?)$/is);
if (!summaryMatch || !summaryMatch[1]) {
logWarn('History: Could not extract summary with regex');
return '';
}
let summary = summaryMatch[1].trim();
// Clean up whitespace between tags but preserve HTML
summary = summary.replace(/>\s+</g, '><');
summary = summary.replace(/\s+/g, ' ');
logDebug('History: Extracted summary (with HTML):', summary.substring(0, 100) + (summary.length > 100 ? '...' : ''));
return summary;
} catch (error) {
logError('History: Error extracting summary:', error);
return '';
}
}
/**
* Extract tap percentage from Tap Analyzer results
*
* @returns {string} Tap percentage or '0.0' if not found
*/
function extractTapPercent() {
try {
const tapResult = document.getElementById('tap-analyzer-result');
if (!tapResult) {
logWarn('History: tap-analyzer-result element not found');
return '0.0';
}
const tapHTML = tapResult.innerHTML;
// Look for pattern: "enter tapped <strong>XX.X%"
const tapMatch = tapHTML.match(/enter tapped <strong>([\d.]+)%/);
const tapPercent = tapMatch ? tapMatch[1] : '0.0';
logDebug('History: Extracted tap percent:', tapPercent);
return tapPercent;
} catch (error) {
logError('History: Error extracting tap percent:', error);
return '0.0';
}
}
/**
* Extract basic land counts from deck list
*
* @param {string} deckList - The deck list text
* @returns {Object} Object with counts for each basic land type
*/
function extractBasicLands(deckList) {
try {
const basicLands = {
W: 0, // Plains
U: 0, // Island
B: 0, // Swamp
R: 0, // Mountain
G: 0, // Forest
C: 0 // Wastes
};
if (!deckList || typeof deckList !== 'string') {
return basicLands;
}
const lines = deckList.trim().split('\n');
for (let line of lines) {
line = line.trim();
if (!line) continue;
// Match pattern: "4 Plains" or "4x Plains"
const match = line.match(/^(\d+)x?\s+(.+)$/i);
if (!match) continue;
const qty = parseInt(match[1], 10);
const cardName = match[2].trim().toLowerCase();
// Check for basic lands (case-insensitive)
if (cardName === 'plains') basicLands.W += qty;
else if (cardName === 'island') basicLands.U += qty;
else if (cardName === 'swamp') basicLands.B += qty;
else if (cardName === 'mountain') basicLands.R += qty;
else if (cardName === 'forest') basicLands.G += qty;
else if (cardName === 'wastes') basicLands.C += qty;
}
logDebug('History: Extracted basic lands:', basicLands);
return basicLands;
} catch (error) {
logError('History: Error extracting basic lands:', error);
return { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 };
}
}
/**
* Format basic lands for display in header
*
* @param {Object} basicLands - Object with W, U, B, R, G, C counts
* @returns {string} Formatted string like "W3 | U3 | B2"
*/
function formatBasicLandsShort(basicLands) {
if (!basicLands) return '';
const colorMap = {
W: '#F0E68C', // Pale yellow/gold for white
U: '#4A90E2', // Blue
B: '#9B59B6', // Purple for black
R: '#D94B3D', // Red
G: '#50A050', // Green
C: '#999999' // Gray for colorless
};
const parts = [];
const colors = ['W', 'U', 'B', 'R', 'G', 'C'];
for (let color of colors) {
if (basicLands[color] > 0) {
parts.push(`<span style="color: ${colorMap[color]}; font-weight: bold;">${color}</span><span style="font-weight: bold;">${basicLands[color]}</span>`);
}
}
return parts.length > 0 ? parts.join(' | ') : 'No basics';
}
/**
* Format basic lands for display in expandable section
*
* @param {Object} basicLands - Object with W, U, B, R, G, C counts
* @returns {string} Formatted string like "3 Plains, 3 Islands, 2 Swamps"
*/
function formatBasicLandsFull(basicLands) {
if (!basicLands) return 'None';
const names = {
W: 'Plains',
U: 'Islands',
B: 'Swamps',
R: 'Mountains',
G: 'Forests',
C: 'Wastes'
};
const colorMap = {
W: '#F0E68C', // Pale yellow/gold for white
U: '#4A90E2', // Blue
B: '#9B59B6', // Purple for black
R: '#D94B3D', // Red
G: '#50A050', // Green
C: '#999999' // Gray for colorless
};
const parts = [];
const colors = ['W', 'U', 'B', 'R', 'G', 'C'];
for (let color of colors) {
if (basicLands[color] > 0) {
parts.push(`<span style="font-weight: bold;">${basicLands[color]} <span style="color: ${colorMap[color]};">${names[color]}</span></span>`);
}
}
return parts.length > 0 ? parts.join(', ') : 'None';
}
/**
* =================================================================
* OPTIMIZER PANEL
* =================================================================
*/
/**
* Analyze deck complexity from website's global variables
* Returns { deckColors, totalBasics }
*/
function analyzeDeckComplexity() {
// Get deck info from the website's global variables (set by loadDict)
const deckColors = typeof window.deckColors !== 'undefined' ? window.deckColors : 0;
// Count basic lands from deck text for complexity check
// (website doesn't expose a basic-only count, we need to calculate it)
const deckTextarea = document.getElementById('deck-list');
const deckText = deckTextarea?.value || '';
const lines = deckText.split('\n');
let totalBasics = 0;
for (const line of lines) {
const trimmed = line.trim().toLowerCase();
const match = trimmed.match(/^(\d+)\s+(.+)$/);
if (!match) continue;
const qty = parseInt(match[1]);
const card = match[2];
// Count basic lands
if (['plains', 'island', 'swamp', 'mountain', 'forest', 'wastes'].includes(card)) {
totalBasics += qty;
}
}
logDebug(`Deck complexity: ${deckColors} colors, ${totalBasics} basics`);
return { deckColors, totalBasics };
}
/**
* Update color analyzer complexity warning (approx-config div) based on deck properties
*/
function updateColorAnalyzerComplexityWarning(complexity) {
const approxConfigDiv = document.getElementById('approx-config');
if (!approxConfigDiv) return;
const { deckColors } = complexity;
// Show/hide the approx-config warning based on color count
if (deckColors >= 5) {
approxConfigDiv.hidden = false;
logDebug('Color Analyzer: Showing approx-config for 5-color deck');
} else {
approxConfigDiv.hidden = true;
logDebug('Color Analyzer: Hiding approx-config');
}
}
/**
* Update optimizer complexity warning based on deck properties
*/
function updateOptimizerComplexityWarning(complexity) {
const warningDiv = document.getElementById('optimizer-complexity-warning');
if (!warningDiv) return;
const { deckColors, totalBasics } = complexity;
// Show/hide warning based on complexity
const isComplex = deckColors >= 4 || totalBasics >= 15;
if (isComplex) {
let message = '';
if (deckColors >= 5 && totalBasics >= 15) {
message = `⚠️ <strong>Warning:</strong> 5-color deck with many basics (${totalBasics}) will take several minutes to optimize. You can cancel at any time.`;
} else if (deckColors >= 5) {
message = `⚠️ <strong>Warning:</strong> 5-color decks require complex calculations and may take several minutes. You can cancel at any time.`;
} else if (deckColors >= 4 && totalBasics >= 15) {
message = `⚠️ <strong>Warning:</strong> 4+ color deck with many basics (${totalBasics}) may take a few minutes to optimize.`;
} else if (deckColors >= 4) {
message = `⚠️ <strong>Warning:</strong> 4+ color decks require more calculations. May take a minute or two.`;
} else if (totalBasics >= 15) {
message = `⚠️ <strong>Warning:</strong> Large number of basics (${totalBasics}) increases optimization time.`;
}
warningDiv.innerHTML = message;
warningDiv.style.display = 'block';
logDebug(`Optimizer: Showing complexity warning (${deckColors} colors, ${totalBasics} basics)`);
} else {
warningDiv.style.display = 'none';
logDebug('Optimizer: Hiding complexity warning');
}
}
function createOptimizerPanel() {
try {
// Check if panel already exists
if (document.getElementById('optimizer-panel')) {
logDebug('Optimizer: Panel already exists');
return;
}
const body = document.body;
if (!body) {
logWarn('Optimizer: Body not found, cannot create panel');
return;
}
// Create panel matching .analyzer class styling
const panel = document.createElement('div');
panel.id = 'optimizer-panel';
panel.className = 'analyzer';
// Override float and width for full-width display
panel.style.cssText = `
width: 100%;
max-width: 1200px;
float: none;
margin: 15px auto;
clear: both;
`;
panel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="margin: 0; font-size: 18px; font-weight: bold;">Basic Land Optimizer</h2>
<input type="button" id="optimize-lands-btn" value="Optimize Basic Lands" disabled>
</div>
<div id="optimizer-options-row" style="display: flex; gap: 20px; align-items: center; margin-bottom: 10px; flex-wrap: nowrap;">
<label id="optimizer-timeout-label" style="font-size: 13px; color: #555; display: flex; align-items: center; gap: 4px; white-space: nowrap;">
Max seconds:
<input type="number" id="optimizer-timeout" value="60" min="10" max="600" step="10"
style="width: 70px; padding: 3px;" title="Maximum time to run optimization" disabled>
</label>
<label id="optimizer-preserve-label" style="font-size: 13px; color: #555; display: flex; align-items: center; gap: 4px; white-space: nowrap;" title="Keep at least 1 land of each type present in the original manabase">
Preserve land types
<input type="checkbox" id="optimizer-preserve-types" checked disabled>
</label>
</div>
<div id="optimizer-status" style="margin-bottom: 10px; font-size: 14px; color: #888;">Load a deck to enable optimizer</div>
<div id="optimizer-complexity-warning" style="display: none; margin-bottom: 10px; padding: 10px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; font-size: 14px; color: #856404;"></div>
<div id="optimizer-results" style="display: none;"></div>
`;
// Append to column C if it exists, otherwise body
const columnC = window.columnC || body;
columnC.appendChild(panel);
logInfo('Optimizer: Panel created');
// Attach optimize button handler
const optimizeBtn = document.getElementById('optimize-lands-btn');
if (optimizeBtn) {
optimizeBtn.addEventListener('click', handleOptimizeClick);
}
} catch (error) {
logError('Optimizer: Error creating panel:', error);
}
}
/**
* =================================================================
* EDH POWER LEVEL PANEL
* =================================================================
* Calculates and displays EDH power level metrics using the
* edhpowerlevel.com algorithm. Uses GM_xmlhttpRequest to fetch
* card data from their API.
*/
// Power Level Configuration (from edhpowerlevel.com algorithm)
const POWER_LEVEL_CONFIG = {
landFactor: 0.6,
favorPrice: 0.25,
powerCurve: [0, 250, 320, 350, 380, 420, 470, 560, 760, 890, 1000],
popCurve: [0, 8500, 13600, 17100, 19800, 21900, 23700, 25300, 26200, 26700, 27000],
priceCurve: [0, 0.5, 1.5, 3.5, 6, 10, 15, 25, 40, 65, 100],
cmcFloor: 1.75,
cmcCeiling: 6,
efficiencyLimits: [0.65, 1.1],
tippingPointThreshold: 0.65,
basicLandImpact: 2,
};
// Card overrides for special cases
const CARD_OVERRIDES = {
"Korvold, Fae-Cursed King": { commanderImpact: 4 },
"Chulane, Teller of Tales": { commanderImpact: 4 },
"Yuriko, the Tiger's Shadow": { commanderImpact: 4 },
"Urza, Lord High Artificer": { commanderImpact: 2 },
"Kinnan, Bonder Prodigy": { commanderImpact: 2 },
"Thrasios, Triton Hero": { commanderImpact: 2.5 },
"Cataclysm": { impact: 1.7 },
"Jokulhaups": { impact: 1.7 },
"Armageddon": { impact: 1.4 },
"Sol Ring": { price: 8 },
"Fierce Guardianship": { cmc: 0 },
"Deflecting Swat": { cmc: 0 },
"Force of Will": { cmc: 0 },
"Force of Negation": { cmc: 0 },
"Cyclonic Rift": { cmc: 5 },
"Blasphemous Act": { cmc: 3 },
"Treasure Cruise": { cmc: 3 },
"Dig Through Time": { cmc: 4 },
};
const BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes',
'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp',
'Snow-Covered Mountain', 'Snow-Covered Forest'];
// Store current power level data for sorting
let currentPowerLevelData = null;
let currentSortColumn = 'impact';
let currentSortAsc = false;
function createPowerLevelPanel() {
try {
if (document.getElementById('powerlevel-panel')) {
return;
}
const body = document.body;
if (!body) return;
const panel = document.createElement('div');
panel.id = 'powerlevel-panel';
panel.className = 'analyzer';
panel.style.cssText = `
width: 100%;
max-width: 1200px;
float: none;
margin: 15px auto;
clear: both;
`;
panel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="margin: 0; font-size: 18px; font-weight: bold;">Power Level</h2>
<input type="button" id="powerlevel-calc-btn" value="Calculate Power Level" disabled>
</div>
<div id="powerlevel-status" style="font-size: 14px; color: #888; margin-bottom: 10px;">
Load a deck to calculate power level
</div>
<div id="powerlevel-dashboard" style="display: none; margin-bottom: 15px;">
<div style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;">
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 6px; border: 1px solid #dee2e6; text-align: center; min-width: 70px; cursor: help;" title="Traditional 1-10 power level from scoring curve">
<div style="font-size: 22px; font-weight: bold; color: #6f42c1;" id="pl-power-level">-</div>
<div style="font-size: 10px; color: #666;">Power</div>
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 6px; border: 1px solid #dee2e6; text-align: center; min-width: 70px; cursor: help;" title="Mana needed to access 65% of deck's non-land impact">
<div style="font-size: 22px; font-weight: bold; color: #fd7e14;" id="pl-tipping">-</div>
<div style="font-size: 10px; color: #666;">Tipping</div>
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 6px; border: 1px solid #dee2e6; text-align: center; min-width: 70px; cursor: help;" title="Based on avg CMC and tipping point. Higher = faster deck">
<div style="font-size: 22px; font-weight: bold; color: #198754;" id="pl-efficiency">-</div>
<div style="font-size: 10px; color: #666;">Efficiency</div>
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 6px; border: 1px solid #dee2e6; text-align: center; min-width: 70px; cursor: help;" title="Sum of card impacts based on price + EDHREC popularity">
<div style="font-size: 22px; font-weight: bold; color: #dc3545;" id="pl-impact">-</div>
<div style="font-size: 10px; color: #666;">Impact</div>
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 6px; border: 1px solid #dee2e6; text-align: center; min-width: 70px; cursor: help;" title="Impact x Efficiency. Linear competitiveness measure (max 1000)">
<div style="font-size: 22px; font-weight: bold; color: #0d6efd;" id="pl-score">-</div>
<div style="font-size: 10px; color: #666;">Score</div>
</div>
</div>
</div>
<div id="powerlevel-table-container" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 4px;">
<table id="powerlevel-table" style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead style="position: sticky; top: 0; background: #f8f9fa; z-index: 1;">
<tr>
<th class="pl-sortable" data-column="name" style="padding: 6px; text-align: left; cursor: pointer; border-bottom: 2px solid #dee2e6; white-space: nowrap;">Card ⇅</th>
<th class="pl-sortable" data-column="price" style="padding: 6px; text-align: right; cursor: pointer; border-bottom: 2px solid #dee2e6; white-space: nowrap;">Price ⇅</th>
<th class="pl-sortable" data-column="priceScore" style="padding: 6px; text-align: right; cursor: pointer; border-bottom: 2px solid #dee2e6; white-space: nowrap;">Cash ⇅</th>
<th class="pl-sortable" data-column="rank" style="padding: 6px; text-align: right; cursor: pointer; border-bottom: 2px solid #dee2e6; white-space: nowrap;">Rank ⇅</th>
<th class="pl-sortable" data-column="popScore" style="padding: 6px; text-align: right; cursor: pointer; border-bottom: 2px solid #dee2e6; white-space: nowrap;">Pop ⇅</th>
<th class="pl-sortable" data-column="impact" style="padding: 6px; text-align: right; cursor: pointer; border-bottom: 2px solid #dee2e6; white-space: nowrap; font-weight: bold;">Impact ⇅</th>
</tr>
</thead>
<tbody id="powerlevel-table-body">
</tbody>
</table>
</div>
`;
// Insert before optimizer panel
const optimizerPanel = document.getElementById('optimizer-panel');
if (optimizerPanel && optimizerPanel.parentNode) {
optimizerPanel.parentNode.insertBefore(panel, optimizerPanel);
} else {
const columnC = unsafeWindow.columnC || body;
columnC.appendChild(panel);
}
// Attach button handler
const calcBtn = document.getElementById('powerlevel-calc-btn');
if (calcBtn) {
calcBtn.addEventListener('click', calculatePowerLevel);
}
// Attach sort handlers
document.querySelectorAll('.pl-sortable').forEach(th => {
th.addEventListener('click', function() {
const column = this.dataset.column;
if (currentSortColumn === column) {
currentSortAsc = !currentSortAsc;
} else {
currentSortColumn = column;
currentSortAsc = column === 'name'; // Ascending for name, descending for numbers
}
renderPowerLevelTable();
});
});
logInfo('PowerLevel: Panel created');
} catch (error) {
logError('PowerLevel: Error creating panel:', error);
}
}
// Curve interpolation function (from edhpowerlevel.com)
function interpolateCurve(value, curve, scale = 1) {
if (value <= curve[0]) return 0;
if (value > curve[curve.length - 1]) return (curve.length - 1) * scale;
for (let i = 0; i < curve.length - 1; i++) {
if (value >= curve[i] && value < curve[i + 1]) {
const progress = (value - curve[i]) / (curve[i + 1] - curve[i]);
return (i + progress) * scale;
}
}
return (curve.length - 1) * scale;
}
function calculatePriceScore(price) {
return interpolateCurve(price, POWER_LEVEL_CONFIG.priceCurve, 1 + POWER_LEVEL_CONFIG.favorPrice);
}
function calculatePopularityScore(edhrec_rank) {
if (edhrec_rank === false || edhrec_rank === null) return 0;
const maxRank = POWER_LEVEL_CONFIG.popCurve[POWER_LEVEL_CONFIG.popCurve.length - 1];
const adjustedRank = maxRank - edhrec_rank;
return interpolateCurve(adjustedRank, POWER_LEVEL_CONFIG.popCurve, 1 - POWER_LEVEL_CONFIG.favorPrice);
}
function calculatePowerLevel() {
const statusDiv = document.getElementById('powerlevel-status');
const dashboardDiv = document.getElementById('powerlevel-dashboard');
const tableContainer = document.getElementById('powerlevel-table-container');
const calcBtn = document.getElementById('powerlevel-calc-btn');
if (!statusDiv) return;
// Get deck list
const decklistElem = document.getElementById('decklist');
if (!decklistElem || !decklistElem.value.trim()) {
statusDiv.innerHTML = '❌ No deck list found. Please load a deck first.';
statusDiv.style.color = '#d32f2f';
return;
}
// Get commander names
const commander1 = document.getElementById('commander-name-1')?.value?.trim() || '';
const commander2 = document.getElementById('commander-name-2')?.value?.trim() || '';
// Parse deck list to get card names
const deckText = decklistElem.value;
const cardNames = [];
const cardQuantities = {};
deckText.split('\n').forEach(line => {
line = line.trim();
if (!line) return;
// Parse "N CardName" or just "CardName"
const match = line.match(/^(\d+)\s+(.+)$/) || line.match(/^(.+)$/);
if (match) {
const qty = match[2] ? parseInt(match[1]) : 1;
const name = (match[2] || match[1]).split(' (')[0].split(' //')[0].trim();
if (name) {
if (!cardQuantities[name]) {
cardNames.push(name);
cardQuantities[name] = 0;
}
cardQuantities[name] += qty;
}
}
});
if (cardNames.length === 0) {
statusDiv.innerHTML = '❌ No cards found in deck list.';
statusDiv.style.color = '#d32f2f';
return;
}
statusDiv.innerHTML = `⏳ Fetching data for ${cardNames.length} cards...`;
statusDiv.style.color = '#888';
calcBtn.disabled = true;
// Fetch from EDH Power Level API
const cardListHeader = cardNames.join('~');
GM_xmlhttpRequest({
method: 'GET',
url: 'https://getcards-4zjvieuafa-uc.a.run.app',
headers: {
'card-list': cardListHeader
},
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data.data && data.data.length > 0) {
processAndDisplayPowerLevel(data.data, cardQuantities, commander1, commander2);
} else {
statusDiv.innerHTML = '❌ API returned no data';
statusDiv.style.color = '#d32f2f';
}
} catch (e) {
statusDiv.innerHTML = '❌ Error parsing API response: ' + e.message;
statusDiv.style.color = '#d32f2f';
logError('PowerLevel: Parse error', e);
} finally {
calcBtn.disabled = false;
}
},
onerror: function(error) {
statusDiv.innerHTML = '❌ API request failed: ' + (error.statusText || 'Network error');
statusDiv.style.color = '#d32f2f';
calcBtn.disabled = false;
}
});
}
function processAndDisplayPowerLevel(apiData, cardQuantities, commander1, commander2) {
const statusDiv = document.getElementById('powerlevel-status');
const dashboardDiv = document.getElementById('powerlevel-dashboard');
const tableContainer = document.getElementById('powerlevel-table-container');
// Build card data with calculations
const cardData = [];
let totalImpact = 0;
let totalCmc = 0;
let landCount = 0;
let nonLandCount = 0;
const gamechangerCards = [];
const cmcImpactMap = new Map();
apiData.forEach(card => {
const name = card.name;
const quantity = cardQuantities[name] || 1;
const price = parseFloat(card.price) || 0;
const edhrec_rank = card.edhrec_rank;
const cmc = card.cmc || 0;
const isLand = card.type_line && card.type_line.split(' // ')[0].split(' — ')[0].includes('Land');
const isModalDFC = card.layout === 'modal_dfc';
const isBasicLand = BASIC_LANDS.includes(name);
const isCommander = name.toLowerCase().includes(commander1.toLowerCase()) ||
(commander2 && name.toLowerCase().includes(commander2.toLowerCase()));
const gamechanger = card.gamechanger || false;
// Get overrides
const override = CARD_OVERRIDES[name] || {};
const effectivePrice = override.price ?? price;
const effectiveCmc = override.cmc ?? cmc;
// Calculate scores
const priceScore = calculatePriceScore(effectivePrice);
const popScore = calculatePopularityScore(edhrec_rank);
// Calculate impact
let impact = (priceScore + popScore) * quantity;
// Apply overrides
if (override.impact) impact *= override.impact;
if (isCommander && override.commanderImpact) impact *= override.commanderImpact;
// Land factor
if (isLand || isModalDFC) {
impact *= POWER_LEVEL_CONFIG.landFactor;
}
// Basic lands get fixed impact
if (isBasicLand) {
impact = POWER_LEVEL_CONFIG.basicLandImpact * quantity;
}
totalImpact += impact;
if (isLand || isModalDFC) {
landCount += quantity;
} else {
totalCmc += effectiveCmc * quantity;
nonLandCount += quantity;
// Track CMC distribution
const current = cmcImpactMap.get(effectiveCmc) || 0;
cmcImpactMap.set(effectiveCmc, current + impact);
}
if (gamechanger) {
gamechangerCards.push(name);
}
cardData.push({
name,
quantity,
price,
priceScore: Math.round(priceScore * 100) / 100,
rank: edhrec_rank || 99999,
popScore: Math.round(popScore * 100) / 100,
impact: Math.round(impact * 100) / 100,
impactPerCard: Math.round((impact / quantity) * 100) / 100,
isLand,
isCommander,
gamechanger,
});
});
// Calculate avg CMC
const avgCmc = nonLandCount > 0 ? totalCmc / nonLandCount : 0;
// Calculate tipping point
const sortedCmcs = Array.from(cmcImpactMap.keys()).sort((a, b) => a - b);
let totalNonLandImpact = 0;
cardData.forEach(c => { if (!c.isLand) totalNonLandImpact += c.impact; });
const threshold = totalNonLandImpact * POWER_LEVEL_CONFIG.tippingPointThreshold;
let cumulative = 0;
let tippingPoint = sortedCmcs[sortedCmcs.length - 1] || 0;
for (const cmc of sortedCmcs) {
cumulative += cmcImpactMap.get(cmc) || 0;
if (cumulative >= threshold) {
tippingPoint = cmc;
break;
}
}
// Calculate efficiency
const combined = (avgCmc + tippingPoint) / 2;
const ratio = Math.max(0, Math.min(1, (POWER_LEVEL_CONFIG.cmcCeiling - combined) / (POWER_LEVEL_CONFIG.cmcCeiling - POWER_LEVEL_CONFIG.cmcFloor)));
const [minEff, maxEff] = POWER_LEVEL_CONFIG.efficiencyLimits;
const efficiencyMultiplier = minEff + (maxEff - minEff) * ratio;
const efficiency = ratio * 10;
// Calculate final score and power level
const score = totalImpact * efficiencyMultiplier;
const powerLevel = interpolateCurve(score, POWER_LEVEL_CONFIG.powerCurve);
// Store for sorting
currentPowerLevelData = {
cards: cardData,
powerLevel: Math.round(powerLevel * 100) / 100,
score: Math.round(score),
efficiency: Math.round(efficiency * 100) / 100,
tippingPoint,
totalImpact: Math.round(totalImpact * 100) / 100,
avgCmc: Math.round(avgCmc * 100) / 100,
landCount,
nonLandCount,
gamechangerCards,
};
// Update dashboard
document.getElementById('pl-power-level').textContent = currentPowerLevelData.powerLevel.toFixed(1);
document.getElementById('pl-score').textContent = currentPowerLevelData.score;
document.getElementById('pl-efficiency').textContent = currentPowerLevelData.efficiency.toFixed(1);
document.getElementById('pl-tipping').textContent = currentPowerLevelData.tippingPoint;
document.getElementById('pl-impact').textContent = currentPowerLevelData.totalImpact.toFixed(0);
// Show dashboard and table
dashboardDiv.style.display = 'block';
tableContainer.style.display = 'block';
statusDiv.innerHTML = `✅ Calculated power level for ${cardData.length} unique cards (${landCount} lands, ${nonLandCount} non-lands)`;
statusDiv.style.color = '#2e7d32';
// Render table
renderPowerLevelTable();
logInfo('PowerLevel: Calculation complete', currentPowerLevelData);
}
function renderPowerLevelTable() {
if (!currentPowerLevelData) return;
const tbody = document.getElementById('powerlevel-table-body');
if (!tbody) return;
// Sort cards
const sortedCards = [...currentPowerLevelData.cards].sort((a, b) => {
let aVal = a[currentSortColumn];
let bVal = b[currentSortColumn];
// Handle string vs number
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (currentSortAsc) {
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
} else {
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
}
});
// Build table rows
let html = '';
sortedCards.forEach(card => {
const rowStyle = card.gamechanger ? 'background: #fff3cd;' : (card.isCommander ? 'background: #e3f2fd;' : (card.isLand ? 'background: #f0f8f0;' : ''));
const namePrefix = (card.isCommander ? '👑 ' : '') + (card.gamechanger ? '🏆 ' : '');
const rankDisplay = card.rank === 99999 ? '-' : `#${card.rank}`;
html += `<tr style="${rowStyle} border-bottom: 1px solid #eee;">
<td style="padding: 6px 8px;">${namePrefix}${card.quantity > 1 ? card.quantity + 'x ' : ''}${card.name}</td>
<td style="padding: 6px 8px; text-align: right;">$${card.price.toFixed(2)}</td>
<td style="padding: 6px 8px; text-align: right;">${card.priceScore.toFixed(2)}</td>
<td style="padding: 6px 8px; text-align: right;">${rankDisplay}</td>
<td style="padding: 6px 8px; text-align: right;">${card.popScore.toFixed(2)}</td>
<td style="padding: 6px 8px; text-align: right; font-weight: bold;">${card.impact.toFixed(2)}</td>
</tr>`;
});
tbody.innerHTML = html;
}
/**
* Get current power level data for history storage
*/
function getPowerLevelHistoryData() {
if (!currentPowerLevelData) return null;
// Get top 5 and bottom 5 non-land, non-gamechanger cards by impact
const nonLandNonGCCards = currentPowerLevelData.cards
.filter(c => !c.isLand && !c.gamechanger)
.sort((a, b) => b.impactPerCard - a.impactPerCard);
const topCards = nonLandNonGCCards.slice(0, 5).map(c => ({ name: c.name, impact: c.impactPerCard }));
const bottomCards = nonLandNonGCCards.slice(-5).reverse().map(c => ({ name: c.name, impact: c.impactPerCard }));
return {
powerLevel: currentPowerLevelData.powerLevel,
score: currentPowerLevelData.score,
efficiency: currentPowerLevelData.efficiency,
tippingPoint: currentPowerLevelData.tippingPoint,
totalImpact: currentPowerLevelData.totalImpact,
avgCmc: currentPowerLevelData.avgCmc,
landCount: currentPowerLevelData.landCount,
nonLandCount: currentPowerLevelData.nonLandCount,
gamechangerCards: currentPowerLevelData.gamechangerCards,
topCards,
bottomCards,
calculatedAt: new Date().toISOString(),
};
}
/**
* Enable power level button after deck load
*/
function enablePowerLevelButton() {
const btn = document.getElementById('powerlevel-calc-btn');
if (btn) {
btn.disabled = false;
}
}
/**
* Disable power level button
*/
function disablePowerLevelButton() {
const btn = document.getElementById('powerlevel-calc-btn');
if (btn) {
btn.disabled = true;
}
}
async function handleOptimizeClick(event) {
console.log('Optimizer: Button clicked');
const optimizeBtn = document.getElementById('optimize-lands-btn');
// If button says "Cancel", it means we're already running - the onclick handler will deal with it
if (optimizeBtn && optimizeBtn.value === 'Cancel Optimization') {
console.log('Optimizer: Cancel button clicked, onclick handler will process');
return; // Let the onclick handler process the cancellation
}
console.log('ManabaseOptimizer version:', ManabaseOptimizer.version || 'unknown');
// Get timeout from input (defined here so error handler can access it)
const timeoutInput = document.getElementById('optimizer-timeout');
const timeoutSeconds = timeoutInput ? parseInt(timeoutInput.value) : 60;
try {
const statusDiv = document.getElementById('optimizer-status');
const resultsDiv = document.getElementById('optimizer-results');
console.log('Optimizer: Elements found', { statusDiv, resultsDiv, optimizeBtn });
// Extract deck data
const deckListElem = document.getElementById('decklist');
if (!deckListElem || !deckListElem.value.trim()) {
statusDiv.textContent = '❌ No deck list found. Please load a deck first.';
statusDiv.style.color = '#d32f2f';
return;
}
const deckText = deckListElem.value;
console.log('Optimizer: Deck text length:', deckText.length);
// Extract commanders
const commanders = [];
const cmdr1 = document.getElementById('commander-name-1');
const cmdr2 = document.getElementById('commander-name-2');
const cmdr3 = document.getElementById('commander-name-3');
if (cmdr1 && cmdr1.value.trim()) commanders.push(cmdr1.value.trim());
if (cmdr2 && cmdr2.value.trim()) commanders.push(cmdr2.value.trim());
if (cmdr3 && cmdr3.value.trim()) commanders.push(cmdr3.value.trim());
// Extract commander weights from dropdowns
const cmdr1WeightElem = document.getElementById('cmdr1-weight');
const cmdr2WeightElem = document.getElementById('cmdr2-weight');
const cmdr3WeightElem = document.getElementById('cmdr3-weight');
const cmdr1Weight = cmdr1WeightElem ? parseInt(cmdr1WeightElem.selectedOptions[0].value) : 30;
const cmdr2Weight = cmdr2WeightElem ? parseInt(cmdr2WeightElem.selectedOptions[0].value) : 30;
const cmdr3Weight = cmdr3WeightElem ? parseInt(cmdr3WeightElem.selectedOptions[0].value) : 15;
// Extract approx colors and samples from page
const approxColorsElem = document.getElementById('approx-colors');
const approxSamplesElem = document.getElementById('approx-samples');
const approxColors = approxColorsElem ? parseFloat(approxColorsElem.value) || 5 : 5;
const approxSamples = approxSamplesElem ? parseFloat(approxSamplesElem.value) || 100000 : 100000;
// Count basic lands
const lines = deckText.split('\n');
const startingLands = { w: 0, u: 0, b: 0, r: 0, g: 0, c: 0 };
for (const line of lines) {
const trimmed = line.trim().toLowerCase();
const match = trimmed.match(/^(\d+)\s+(.+)$/);
if (!match) continue;
const qty = parseInt(match[1]);
const card = match[2];
if (card === 'plains') startingLands.w += qty;
else if (card === 'island') startingLands.u += qty;
else if (card === 'swamp') startingLands.b += qty;
else if (card === 'mountain') startingLands.r += qty;
else if (card === 'forest') startingLands.g += qty;
else if (card === 'wastes') startingLands.c += qty;
}
const totalBasics = Object.values(startingLands).reduce((a, b) => a + b, 0);
console.log('Optimizer: Starting lands:', startingLands, 'total:', totalBasics);
if (totalBasics === 0) {
statusDiv.textContent = '⚠️ No basic lands found in deck.';
statusDiv.style.color = '#ff9800';
resultsDiv.style.display = 'none';
return;
}
// Get ignore/discount list from textarea
const ignoreTextarea = document.getElementById('ignore');
const ignoreList = ignoreTextarea ? ignoreTextarea.value : '';
console.log('Optimizer: Ignore/discount list:', ignoreList ? ignoreList.split('\n').length + ' entries' : 'none');
// Calculate timeout in milliseconds
const timeoutMs = timeoutSeconds * 1000;
// Create AbortController for cancellation
const abortController = new AbortController();
// Timer for elapsed time display
const startTime = Date.now();
let timerInterval = null;
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const remaining = Math.max(0, timeoutSeconds - elapsed);
statusDiv.innerHTML = `⏳ Optimizing... ${elapsed}s elapsed, ${remaining}s remaining`;
};
// Change button to Cancel with red styling, hide timeout controls and preserve checkbox
optimizeBtn.disabled = false; // Enable so it can be clicked to cancel
optimizeBtn.value = 'Cancel Optimization';
optimizeBtn.style.backgroundColor = '#e74c3c';
optimizeBtn.style.color = 'white';
// Disable options during optimization
const preserveCheckbox = document.getElementById('optimizer-preserve-types');
const timeoutInput = document.getElementById('optimizer-timeout');
const preserveOriginalTypes = preserveCheckbox ? preserveCheckbox.checked : true;
if (preserveCheckbox) preserveCheckbox.disabled = true;
if (timeoutInput) timeoutInput.disabled = true;
optimizeBtn.onclick = () => {
console.log('Optimizer: User cancelled optimization');
if (timerInterval) clearInterval(timerInterval);
statusDiv.textContent = '⏹️ Cancelling...';
statusDiv.style.color = '#ff9800';
abortController.abort();
};
// Get testDict from page if available (global var from website)
// Note: Even with @grant none, we're in page context so can access directly
let testDict = null;
try {
if (typeof window.testDict !== 'undefined' && window.testDict) {
testDict = window.testDict;
}
} catch (e) {
console.warn('Optimizer: Could not access testDict:', e);
}
console.log('Optimizer: testDict available:', testDict ? Object.keys(testDict).length + ' cards' : 'none');
// Show appropriate loading message
if (testDict) {
statusDiv.textContent = '⏳ Using loaded deck data...';
} else {
statusDiv.textContent = '⏳ Loading cards from Scryfall...';
}
statusDiv.style.color = '#555';
resultsDiv.style.display = 'none';
// Start timer once optimization begins
timerInterval = setInterval(updateTimer, 1000);
console.log('Optimizer: About to call optimizeLands, ManabaseOptimizer:', typeof ManabaseOptimizer);
console.log('Optimizer: optimizeLands function:', typeof ManabaseOptimizer.optimizeLands);
// Run optimization with timeout
// Use setTimeout to ensure we release the event loop before starting heavy work
let result;
try {
await new Promise(resolve => setTimeout(resolve, 10));
const optimizePromise = ManabaseOptimizer.optimizeLands({
deckList: deckText,
commanders: commanders,
startingLands: startingLands,
ignoreList: ignoreList,
options: {
topN: 5,
maxIterations: 50,
testDict: testDict,
signal: abortController.signal,
preserveOriginalTypes: preserveOriginalTypes,
calculatorOptions: {
approxColors: approxColors,
approxSamples: approxSamples,
cmdr1Weight: cmdr1Weight,
cmdr2Weight: commanders.length >= 2 ? cmdr2Weight : undefined,
cmdr3Weight: commanders.length >= 3 ? cmdr3Weight : undefined
},
onProgress: (current, total, status, topResults) => {
console.log('Optimizer progress:', status);
// Display live top 5 results asynchronously
Promise.resolve().then(() => {
if (topResults && topResults.length > 0) {
displayOptimizerResults({ results: topResults, statistics: { tested: current, improved: 0, duration: 0 } }, startingLands);
resultsDiv.style.display = 'block';
}
});
}
}
});
const timeoutPromise = new Promise((_, reject) => {
const timeoutId = setTimeout(() => {
console.log('Optimizer: Timeout reached, aborting...');
if (timerInterval) clearInterval(timerInterval);
statusDiv.textContent = '⏱️ Timeout reached, stopping...';
statusDiv.style.color = '#ff9800';
abortController.abort();
reject(new Error(`Optimization timed out after ${timeoutSeconds}s`));
}, timeoutMs);
// Clean up timeout if optimization finishes first
optimizePromise.finally(() => clearTimeout(timeoutId));
});
result = await Promise.race([optimizePromise, timeoutPromise]);
console.log('Optimizer: Got result:', result);
// Clear timer on success
if (timerInterval) clearInterval(timerInterval);
} catch (innerError) {
console.error('Optimizer: Error in optimizeLands call:', innerError);
if (timerInterval) clearInterval(timerInterval);
throw innerError;
}
// Display results
displayOptimizerResults(result, startingLands);
statusDiv.textContent = `✅ Optimization complete! Tested ${result.statistics.tested} configurations in ${(result.statistics.duration / 1000).toFixed(1)}s`;
statusDiv.style.color = '#2e7d32';
} catch (error) {
console.error('Optimizer: Error during optimization:', error);
console.error('Optimizer: Error stack:', error.stack);
logError('Optimizer: Error during optimization:', error);
const statusDiv = document.getElementById('optimizer-status');
// Check if it's a cancellation or timeout (user actions, not errors)
const isCancelled = error.message.includes('cancel') || error.message.includes('abort');
const isTimeout = error.message.includes('timed out');
if (statusDiv) {
if (isCancelled) {
console.log('Optimizer: Setting cancelled status');
statusDiv.textContent = '⏹️ Optimization cancelled';
statusDiv.style.color = '#ff9800'; // Orange for cancelled
} else if (isTimeout) {
console.log('Optimizer: Setting timeout status');
statusDiv.textContent = `⏱️ Optimization timed out after ${timeoutSeconds}s`;
statusDiv.style.color = '#ff9800'; // Orange for timeout
} else {
// Actual error
console.log('Optimizer: Setting error status');
statusDiv.textContent = `❌ Error: ${error.message}`;
statusDiv.style.color = '#d32f2f';
}
}
// Show partial results if available (they were displayed during progress updates)
// Results display is already visible from onProgress updates, just update status
} finally {
const optimizeBtn = document.getElementById('optimize-lands-btn');
const preserveCheckbox = document.getElementById('optimizer-preserve-types');
const timeoutInput = document.getElementById('optimizer-timeout');
if (optimizeBtn) {
optimizeBtn.disabled = false;
optimizeBtn.value = 'Optimize Basic Lands';
optimizeBtn.style.backgroundColor = '';
optimizeBtn.style.color = '';
optimizeBtn.onclick = null; // Remove cancel handler
}
// Re-enable options
if (preserveCheckbox) preserveCheckbox.disabled = false;
if (timeoutInput) timeoutInput.disabled = false;
}
}
function displayOptimizerResults(result, startingLands) {
const resultsDiv = document.getElementById('optimizer-results');
if (!resultsDiv) return;
console.log('Displaying', result.results.length, 'results');
let html = '<div class="result-box" style="margin-top: 10px;">';
html += '<strong>Recommended Configurations:</strong><br><br>';
for (let i = 0; i < result.results.length; i++) {
const config = result.results[i];
const isCurrent = ManabaseOptimizer.hashLands(config.lands) === ManabaseOptimizer.hashLands(startingLands);
html += '<div style="margin: 8px 0; padding: 8px; border: 1px solid #ccc;';
if (isCurrent) html += ' background-color: #e3f2fd; border-color: #2196F3;';
html += '">';
if (isCurrent) {
html += '<strong style="color: #1976D2;">Current Configuration</strong><br>';
}
html += `<div style="display: flex; justify-content: space-between; align-items: center;">`;
html += `<div style="flex: 1;">`;
html += `<div style="margin: 5px 0;">`;
html += formatLandDistribution(config.lands);
html += '</div>';
html += `<div style="font-size: 13px; color: #333;">`;
html += `Cast Rate: <strong>${(config.castRate * 100).toFixed(1)}%</strong> | `;
html += `Avg Delay: <strong>${config.avgDelay.toFixed(3)}</strong> turns`;
html += '</div>';
if (!isCurrent) {
const changes = ManabaseOptimizer.describeLandChanges(startingLands, config.lands);
html += `<div style="margin-top: 5px; font-size: 12px; font-style: italic;">`;
html += `${changes}`;
html += '</div>';
}
html += '</div>'; // close flex: 1
// Apply button matching website style
if (!isCurrent) {
html += `<button class="apply-lands-btn" data-lands="${encodeURIComponent(JSON.stringify(config.lands))}" style="
background-color: #77ee77;
border-radius: 15px;
border: 1px solid #383;
cursor: pointer;
padding: 5px 15px;
font-size: 15px;
font-weight: bold;
box-shadow: 0 3px #383;
transition: 0.3s ease-out;
margin-left: 10px;
"
onmouseover="this.style.backgroundColor='#3e3'"
onmouseout="this.style.backgroundColor='#77ee77'"
onmousedown="this.style.backgroundColor='#5c5'; this.style.boxShadow='0 1px #383'; this.style.transform='translateY(2px)'"
onmouseup="this.style.boxShadow='0 3px #383'; this.style.transform='translateY(0px)'"
title="Replace basic lands in deck list with this configuration">Apply</button>`;
}
html += '</div>'; // close flex container
html += '</div>';
}
html += '</div>';
resultsDiv.innerHTML = html;
resultsDiv.style.display = 'block';
// Attach apply button handlers
const applyButtons = resultsDiv.querySelectorAll('.apply-lands-btn');
applyButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
const landsJson = decodeURIComponent(e.target.dataset.lands);
const lands = JSON.parse(landsJson);
applyLandsToDeckList(lands);
});
});
}
function applyLandsToDeckList(lands) {
const decklistTextarea = document.getElementById('decklist');
if (!decklistTextarea) {
console.error('Optimizer: Could not find decklist textarea');
return;
}
// Get current deck list
const currentDeckList = decklistTextarea.value;
const lines = currentDeckList.split('\n');
// Basic land names in WUBRG(C) order
const basicNames = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes'];
const landMap = { w: 'Plains', u: 'Island', b: 'Swamp', r: 'Mountain', g: 'Forest', c: 'Wastes' };
const landOrder = ['w', 'u', 'b', 'r', 'g', 'c']; // WUBRG(C) order
// Track where first basic was found
let firstBasicIndex = -1;
// Collect basics to insert (in WUBRG order)
const basicsToInsert = [];
for (const key of landOrder) {
if (lands[key] > 0) {
basicsToInsert.push(`${lands[key]} ${landMap[key]}`);
}
}
// Remove all existing basics from deck list
const newLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim().toLowerCase();
if (!trimmed) {
newLines.push(line); // Keep empty lines
continue;
}
// Check if line starts with number and contains a basic land name
const match = trimmed.match(/^(\d+)\s+(.+)$/);
if (!match) {
newLines.push(line); // Not a card line, keep it
continue;
}
const cardName = match[2];
const isBasic = basicNames.some(basic =>
cardName === basic.toLowerCase() ||
cardName.startsWith(basic.toLowerCase() + ' (')
);
if (isBasic) {
// Track where first basic was found for insertion point
if (firstBasicIndex === -1) {
firstBasicIndex = newLines.length;
}
// Skip all basics - we'll insert them sorted later
} else {
// Not a basic, keep it
newLines.push(line);
}
}
// Insert all basics (sorted in WUBRG order) at the first basic position
if (basicsToInsert.length > 0) {
if (firstBasicIndex >= 0) {
// Insert at first basic position
newLines.splice(firstBasicIndex, 0, ...basicsToInsert);
} else {
// No basics found in original, add at end
newLines.push(...basicsToInsert);
}
}
const newDeckList = newLines.join('\n');
decklistTextarea.value = newDeckList;
// Scroll to the decklist textarea
decklistTextarea.scrollIntoView({ behavior: 'smooth', block: 'center' });
console.log('Optimizer: Applied new basic land configuration');
}
function formatLandDistribution(lands) {
const names = { w: 'Plains', u: 'Island', b: 'Swamp', r: 'Mountain', g: 'Forest', c: 'Wastes' };
const colors = { w: '#F0E68C', u: '#4A90E2', b: '#9B59B6', r: '#D94B3D', g: '#50A050', c: '#999999' };
const parts = [];
for (const [key, name] of Object.entries(names)) {
if (lands[key] > 0) {
parts.push(`<span style="font-weight: bold;">${lands[key]} <span style="color: ${colors[key]};">${name}</span></span>`);
}
}
return parts.length > 0 ? parts.join(', ') : 'None';
}
/**
* =================================================================
* LAYOUT RESTRUCTURING
* =================================================================
*/
function restructureLayout() {
try {
logDebug('Layout: Restructuring to 3-column layout');
// Inject CSS for 3-column layout
const style = document.createElement('style');
style.textContent = `
body {
margin: 0 !important;
padding: 0 !important;
}
.layout-container {
display: flex;
width: 100%;
min-height: 100vh;
gap: 0;
}
.layout-column {
flex: 1;
width: 33.333%;
padding: 10px;
overflow-y: auto;
max-height: 100vh;
box-sizing: border-box;
}
.column-a {
background-color: #f8f8f8;
}
.column-b {
background-color: #ffffff;
}
.column-c {
background-color: #f8f8f8;
}
.analyzer {
width: 100% !important;
margin: 10px 0 !important;
}
`;
document.head.appendChild(style);
// Get the analyzers
const deckLoader = document.querySelector('.analyzer[name="deckEntry"]');
const colorAnalyzer = document.querySelector('.analyzer[name="colorCalc"]');
const tapAnalyzer = document.querySelector('.analyzer[name="tapCalc"]');
const historyPanel = document.getElementById('history-panel');
const optimizerPanel = document.getElementById('optimizer-panel');
if (!deckLoader || !colorAnalyzer || !tapAnalyzer) {
logError('Layout: Could not find required elements');
return;
}
// Create container and columns
const container = document.createElement('div');
container.className = 'layout-container';
const columnA = document.createElement('div');
columnA.className = 'layout-column column-a';
const columnB = document.createElement('div');
columnB.className = 'layout-column column-b';
const columnC = document.createElement('div');
columnC.className = 'layout-column column-c';
// Column A: Deck Loader + History
columnA.appendChild(deckLoader);
if (historyPanel) {
columnA.appendChild(historyPanel);
}
// Column B: Color Analyzer + Tap Analyzer
columnB.appendChild(colorAnalyzer);
columnB.appendChild(tapAnalyzer);
// Column C: Optimizer (will be added by createOptimizerPanel)
if (optimizerPanel) {
columnC.appendChild(optimizerPanel);
}
// Add columns to container
container.appendChild(columnA);
container.appendChild(columnB);
container.appendChild(columnC);
// Replace body content
document.body.innerHTML = '';
document.body.appendChild(container);
// Store column C reference for optimizer panel
window.columnC = columnC;
logInfo('Layout: 3-column layout created successfully');
} catch (error) {
logError('Layout: Error restructuring layout:', error);
}
}
/**
* =================================================================
* STARTUP
* =================================================================
*/
/**
* Set Page Title and Favicon
*
* Changes the page title from "Document" to "Mana Tool" and sets
* the favicon to match the Salubrious Snail website.
*/
function setPageMetadata() {
// Set title
document.title = 'Mana Tool';
// Set favicon - using the same as https://www.salubrioussnail.com/
const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link');
favicon.rel = 'icon';
favicon.href = 'https://lh3.googleusercontent.com/sitesv/APaQ0STwZejRnUtnxosdcNAeWiC1wGxAp1hshR_MqcxUooRZyEjbrGrKEVigv_nKftwDGhFLmlyRgjfoI-2ixV4vGfaoz6XHbPANR1swqcXFbUc1F96CQO0VUioiI1-yftvtyE2KcuJ6sAuG2UOaSoSlELhK7TljxSG6Uf_tj6ldHdOjiHLy8dloAuFLSMwy0q5cNi7JJgJjCyj2HXBQ20E';
if (!document.querySelector('link[rel="icon"]')) {
document.head.appendChild(favicon);
}
}
// Set page metadata immediately
setPageMetadata();
// Wait for DOM to be ready before initializing
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
restructureLayout();
initializeScript();
initializeHistory();
});
} else {
// DOM already loaded (in case script runs late)
restructureLayout();
initializeScript();
initializeHistory();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment