Skip to content

Instantly share code, notes, and snippets.

@pakoito
Last active February 10, 2026 20:34
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.3.1
// @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=1770755660401
// @grant none
// @run-at document-start
// ==/UserScript==
/**
* Manabase Tool Auto-Analyzer v3.3.1
*
* 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
* - 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';
/**
* 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 button + timeout input
const optimizeBtn = document.getElementById('optimize-lands-btn');
const timeoutInput = document.getElementById('optimizer-timeout');
const timeoutLabel = document.getElementById('optimizer-timeout-label');
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');
}
if (timeoutInput) {
timeoutInput.disabled = false;
timeoutInput.style.display = '';
logDebug('Optimizer: Timeout input enabled after deck load');
}
if (timeoutLabel) {
timeoutLabel.style.display = '';
logDebug('Optimizer: Timeout label shown after deck load');
}
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 Latin-1, then re-encoded as UTF-8
// Reverse by treating each codepoint as a Latin-1 byte value and re-decoding as UTF-8
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 {
// Character outside Latin-1 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
* =================================================================
*/
/**
* Initialize History Feature
*
* Setup analyzer completion observers and create the history panel.
*/
function initializeHistory() {
setupAnalyzerCompletionObservers();
createClipboardPasteButton();
createOptimizerPanel();
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;
}
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);
// 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');
}
// Assemble complete entry
const entry = {
timestamp: new Date().toISOString(),
deckList: deckList,
commanders: commanders,
manabase: manabase,
summary: summary,
tapPercent: tapPercent,
basicLands: basicLands,
diff: diff,
changeCount: changeCount
};
// 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; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0; font-size: 20px; color: #333;">Analysis History</h2>
<div style="display: flex; gap: 10px;">
<input type="button" id="clear-history-btn" value="Clear History"
style="width: auto; padding: 5px 15px; font-size: 13px; cursor: pointer;">
<input type="button" id="collapse-all-btn" value="Collapse All"
style="width: auto; padding: 5px 15px; font-size: 13px; cursor: pointer;">
<input type="button" id="expand-all-btn" value="Expand All"
style="width: auto; padding: 5px 15px; font-size: 13px; 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');
}
// 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;
}
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;
// 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;
}
// 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; gap: 20px; 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: 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;">
<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>
<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);
}
}
});
});
logDebug('History: Event listeners attached');
} catch (error) {
logError('History: Error attaching event listeners:', error);
}
}
/**
* 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;
}
// 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: 10px;">
<h2 style="margin: 0; font-size: 18px; font-weight: bold;">Basic Land Optimizer</h2>
<div style="display: flex; gap: 10px; align-items: center;">
<label id="optimizer-timeout-label" style="font-size: 13px; color: #555;">
Timeout (seconds):
<input type="number" id="optimizer-timeout" value="60" min="10" max="600" step="10"
style="width: 70px; padding: 3px; margin-left: 5px;" title="Maximum time to run optimization" disabled>
</label>
<input type="button" id="optimize-lands-btn" value="Optimize Basic Lands" disabled>
</div>
</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);
}
}
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: ${elapsed}s / Timeout: ${timeoutSeconds}s (${remaining}s remaining)`;
};
// Change button to Cancel with red styling, hide timeout controls
optimizeBtn.disabled = false; // Enable so it can be clicked to cancel
optimizeBtn.value = 'Cancel Optimization';
optimizeBtn.style.backgroundColor = '#e74c3c';
optimizeBtn.style.color = 'white';
// Hide and disable timeout controls during optimization
if (timeoutInput) {
timeoutInput.disabled = true;
timeoutInput.style.display = 'none';
}
const timeoutLabel = document.getElementById('optimizer-timeout-label');
if (timeoutLabel) timeoutLabel.style.display = 'none';
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,
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 timeoutInput = document.getElementById('optimizer-timeout');
const timeoutLabel = document.getElementById('optimizer-timeout-label');
if (optimizeBtn) {
optimizeBtn.disabled = false;
optimizeBtn.value = 'Optimize Basic Lands';
optimizeBtn.style.backgroundColor = '';
optimizeBtn.style.color = '';
optimizeBtn.onclick = null; // Remove cancel handler
}
if (timeoutInput) {
timeoutInput.disabled = false;
timeoutInput.style.display = ''; // Show timeout input again
}
if (timeoutLabel) {
timeoutLabel.style.display = ''; // Show timeout label again
}
}
}
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