• Smogon Premier League is here and the team collection is now available. Support your team!

Resource Player Toolsheet Thread

LouisCyphre

heralds disaster.
is a Community Leaderis a Top Community Contributor
BBP Leader
boxes.png
profileGenerator.png


CAP BBP Player Toolsheet

Get the Toolsheet right here!​



-- link to profile generator and rundown of its features
-- direct bug reports to the existing bug thread
-- how to get to the profile generator's code (extensions -> app script). in text and pictures
-- the most current gdscript of the profile generator (so that people can just paste it themselves)

Pasting from the Toolsheet
-- turn off BBCode when pasting. (picture)
-- the forum tries to "help" you paste, but it assumes too much about what you mean by formatting. it removes line breaks, changes the spacing of your tables, and rearranges your formatting tags.


Using the Toolsheet
-- I wanted many more options, but I am only one dev. Google Sheets is very limiting.


-- import sheets are hidden
----
1762166843079.png


-- Once the sheet finishes loading, a new menu will appear at the top of the page. Look for the Wizard.
----
1762230879520.png


-- post containing the profile format for every venue (mostly for us, but also in case users want to copy a particular part)
----- i expect "we used notes for hp / en / capture tracker" or "here's how we added bios" to be helpful

-- players can post their own formats or share editor-editing tips
 
Last edited:
Current Toolsheet Code

Sheet-Level Functions.gs
JavaScript:
// The PropertiesService used to store persistent variables in the spreadsheet's data.
const scriptProperties = PropertiesService.getScriptProperties();
// Shorthand reference for the entire Toolsheet. Helps keep code short.
const sheetRef = SpreadsheetApp.getActiveSpreadsheet();
// The Sheet Names of the individual sheets. If you rename your sheets, you
// can update these variables to restore script functionality.
const pokemonListSheetName = "Boxes"; // PokemonList
const movepoolViewerSheetName = "Movepool"; // Movepool Viewer


const importPokemonSheetName = "DAT Pokemon";
const importDisciplinesSheetName = "DAT Disciplines";
const importMovesSheetName = "DAT Moves";
const importLearningSheetName = "DAT Learnsets";
const importMoveLevelsSheetName = "DAT Levels";
const importPokemonFormesSheetName = "DAT Formes";
const importPokemonTechsSheetName = "DAT Techs";
const importSheets = [ // Array of all sheets that import tables from the DAT or Movepool Reference.
  importPokemonSheetName, importDisciplinesSheetName, importMovesSheetName, importLearningSheetName,
  importMoveLevelsSheetName, importPokemonFormesSheetName, importPokemonTechsSheetName
]


// Named Ranges. Each corresponds to a range of data imported from the DAT.
const moveBattleDataRangeName = "ImportedMoveBattleData";
const moveContestDataRangeName = "ImportedMoveContestData";
const moveDataRangeName = "ImportedMoveData";
const moveLevelsRangeName = "ImportedMoveLevelData";
const learningsDataRangeName = "ImportedPokemonMovepoolData";
const pokemonDataRangeName = "ImportedPokemonData";
const pokemonFormesRangeName = "ImportedPokemonFormesData";
const pokemonNamesRangeName = "ImportedPokemonNames";
const pokemonTechsRangeName = "ImportedPokemonTechsData";


/** 
 * Retrieves the web-page UI for Google Sheets. Used to display prompts and add menus.
 */
function getUI() {
  return SpreadsheetApp.getUi();
}


/**
 * Setter function for queuing cache operations. Used to ensure rapidly-consecutive
 * cache operations don't eat one another.
 * 
 * The cache can only receive String values, so adding items to the Cache requires
 * liberal use of JSON.stringify() and JSON.parse().
 * 
 * The Cache key "visibilityCache" is an object of objects. Each key within, having a unique
 * name (hopefully), keys to an object containing that object's sheet name, range and expected state.
 */
function queueToCacheArray(masterKey, value) {
  const cache = CacheService.getScriptCache();
  const cachedItem = cache.get(masterKey); // Retrieve the requested Cache-of-Caches, such as the visibilityCache
  let parsedCache = { }; // Default value, in case the Cached object doesn't exist.
  const parsedValue = JSON.parse(value); // Pre-parse the passed object, while keeping the unparsed passed String.
  const parsedValueName = parsedValue["uniqueName"];
  if ( cachedItem ) {
    parsedCache = JSON.parse(cachedItem);
    Logger.log( "Retrieved " + masterKey + " (" + typeof parsedCache + "): " + cachedItem );
    if ( "object" != typeof parsedCache ) { 
      // If the cached Item was anything but an Object, reset it.
      Logger.log ( "Correcting " + masterKey )
      parsedCache = { }
    }
  } else {
    Logger.log( "Creating " + masterKey)
    // We already have an empty { } for parsedCache, no action is needed here.
  }
  Logger.log("Added " + parsedValueName + " to " + masterKey);
  parsedCache[parsedValueName] = value;
  cache.put(masterKey, JSON.stringify(parsedCache));
}


/** 
 * Creates an additional menu called "CAP BBP Scripts", only for users with edit permissions.
 * 
 * When you create your own copy of the Toolsheet, that user will be you.
 * 
 * By clicking any added menu item, the sheet will prompt you to authorize the sheet's scripts
 * to run. Do so by signing in to a Google account in the window that appears.
 * 
 * Once you do, each of these functions will be available to help manage your Toolsheet.
 */
/** @OnlyCurrentDoc */
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('‍♂️ CAP BBP Wizard')
  .addItem("Enable necessary scripts for Toolsheet", 'dummyActivationMessage')
  .addSeparator()
  .addSubMenu(ui.createMenu("Pokemon Boxes")
    .addItem("Fold all Boxes", 'foldAllBoxes')
    .addItem("Add New Empty Box", 'newEmptyBox')
    .addItem("Move all loose Pokemon to New Box", 'newBoxLoosePokemon')
    .addSeparator()
    .addItem("Sort Boxes", 'sortBoxes')
    .addItem("Sort visible Pokemon within Boxes", 'sortPokemonWithinVisibleBoxes')
    .addItem("Format all Box headers", 'formatAllBoxHeaders')
    .addItem("Format all Box and Pokemon rows", 'formatAllBoxAndPokemonRows')
    .addSeparator()
    .addItem("Delete all Boxes, leaving all Pokemon loose", `explodeAllBoxes`)
    .addItem("Delete all Boxes that contain no Pokemon", `deleteEmptyBoxes`)
    .addItem("Clean up all Box formatting", 'formatAllPokemonBoxes')
    .addItem("Display Box Report", 'displayBoxReport')
    .addSeparator()
    .addItem("Forcibly unhide all Box rows and columns", 'forceDisplayBoxRowsColumns')
  )
  .addSeparator()
  .addSubMenu(ui.createMenu("Matchup Analyzer")
    .addItem("Clear Matchup Pokemon Configuration", 'clearMatchupPokemonConfig')
  )
  .addSeparator()
  .addSubMenu(ui.createMenu("Data-Import Sheets")
    .addItem("Reveal all Data-Import Sheets", "revealAllImportSheets")
    .addItem("Hide all Data-Import Sheets", "hideAllImportSheets")
  )
  .addToUi();
  speciesCache = {};
}


function dummyActivationMessage() {
  Browser.msgBox('Scripts are enabled.');
}
function revealAllImportSheets() {
  importSheets.forEach( (sheetName) => sheetRef.getSheetByName(sheetName).showSheet() );
  sheetRef.getSheetByName(importSheets[0]).activate();
}
function hideAllImportSheets() {
  importSheets.forEach( (sheetName) => sheetRef.getSheetByName(sheetName).hideSheet() );
}


/** ON EDIT
 * This function is called whenever an edit is made to the sheet.
 * 
 * By setting flags in the Script Properties, custom functions can be made to do things here.
 */
function onEdit(e) {
  const editedSheet = e.range.getSheet();
  const editedSheetName = editedSheet.getName();
  const cache = CacheService.getScriptCache();
  const unparsedCache = cache.get("visibilityCache");
  let visibilityCache = JSON.parse(unparsedCache);
  for ( const queuedKey in visibilityCache ) {
    const queuedEntry = JSON.parse(visibilityCache[queuedKey]);
    if (queuedEntry.sheetName == editedSheetName) {
      if ( queuedEntry.visible ) {
        if (queuedEntry.useRows) { editedSheet.showRows(queuedEntry.startIndex, queuedEntry.indexSpan); }
        if (!queuedEntry.useRows) { editedSheet.showColumns(queuedEntry.startIndex, queuedEntry.indexSpan); }
      } else {
        if (queuedEntry.useRows) { editedSheet.hideRows(queuedEntry.startIndex, queuedEntry.indexSpan); }
        if (!queuedEntry.useRows) { editedSheet.hideColumns(queuedEntry.startIndex, queuedEntry.indexSpan); }
      }
    }
  }
  
}

/** 
 * Based on an input Boolean (such as a checkbox), toggles a specified range of columns to be shown if TRUE, or hidden if FALSE.
 * 
 * More specifically, the function sets a flag and stores a range to be shown or hidden, each time it is calculated. Another
 * function later performs the actual showing or hiding, depending on the stored ranges, if the specified flag is set.
 * 
 * @param {boolean} visible Whether or not the specified range should be visible.
 * @param {String} uniqueName An arbitrary name for identifying the range to be toggled; e.g. "Options_1"
 * @param {String} sheetName The name of the sheet containing the desired rows or columns.
 * @param {number} startIndex The first row index or column index to be shown or hidden.
 * @param {number} endIndex The last row index or column index to be shown or hidden.
 * @param {boolean} useRows Whether to hide rows (if true) or columns (if false)
 * @param {String|Array<Array<String>>} showOutput Optional output value for if the range is to be shown. E.g. "Showing Stats."
 * @param {String|Array<Array<String>>} hideOutput Optional output value for if the range is to be hidden. E.g. "Hiding Stats."
 * 
 * @return Returns the specified showOutput or hideOutput string, depending on if visible is true or not.
 * 
 * @customfunction
 */
function TOGGLEVISIBLE(visible, uniqueName, sheetName, startIndex, endIndex, useRows = true, showOutput = "Showing", hideOutput = "Hiding") {
  const cache = CacheService.getScriptCache();
  let numRowsOrColumns = Math.abs(endIndex - startIndex) + 1;
  numRowsOrColumns = (numRowsOrColumns > 1) ? numRowsOrColumns : 1;
  let array = cache.get("visibilityQueue");
  array = ( array ) ? array : []; // Make a blank array, if nothing is cached yet.
  const toggleRangeObject = {
    "uniqueName": uniqueName,
    "visible": visible,
    "sheetName": sheetName,
    "startIndex": startIndex,
    "indexSpan": numRowsOrColumns,
    "useRows": useRows
  }
  queueToCacheArray("visibilityCache", JSON.stringify(toggleRangeObject));
  return visible ? showOutput : hideOutput;
}


/**
* Column to Letter
* from StackOverflow: http://stackoverflow.com/questions/21229180/convert-column-index-into-corresponding-column-letter
*/
function columnToLetter(column) {
  var temp, letter = '';
  while (column > 0) {
    temp = (column - 1) % 26;
    letter = String.fromCharCode(temp + 65) + letter;
    column = (column - temp - 1) / 26;
  }
  return letter;
}

Box Functions.gs
JavaScript:
// REMEMBER: Google Sheets initializes Global Variables EVERY TIME A CELL CALLS A FUNCTION.
//           They cannot be used for long-term storage.
/** GLOBALS */
// The number of rows devoted to Header space in the Box sheet, before Boxes and Pokemon begin.
const headerRowCount = 8;
// The Species Column global tells the Box-finding functions which column to check for the word "BOX".
const speciesColumn = 2;
const nicknameColumn = 3;
const expColumn = 4;
const levelColumn = 5;
// The Box Phrase is the keyword used in the Species column to designate a row as a Box-Starting Row.
function getBoxPhrase() { return sheetRef.getRangeByName("OptionsBoxPhrase").getValue(); } // Reads the Options Sheet for the Box Phrase to search for.
function getBoxHeaders() { return sheetRef.getRangeByName("BoxesHeaderRow").getValues(); } // Retrieves an array of Header names from the Box Sheet.


/**
 * Receives a Species name and a Level; and returns the number of Combo Slots
 * the Pokemon should have.
 * 
 * @param (String) speciesName The species of the Pokemon.
 * @param (int) level The Level of the Pokemon.
 * 
 * @customfunction
 */
function COMBOSLOTS(speciesName, level) {
  // Because this function is short and hardcoded, it is listed first so that
  // it's harder to overlook if other Pokemon ever have Combo Slot changes.
  if ( 2 > level ) { return 0; }
  if ( "Unown" == speciesName ) { return 0; } // Unown gets no Combo Slots.
  if ( "Smeargle" == speciesName ) { return level - 1; } // Smeargle gets half Combo Slots.
  return 2 * (level - 1);
}


/**
 * Given a row index, returns whether row contains no Pokemon and no Box header
 * (whether it's "unusued", in the way Boxes care about), by checking the
 * Species column and the Nickname column for any present content.
 */
function rowIsUsed(rowIndex) {
  const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
  const speciesValue = boxSheet.getRange("R" + rowIndex.toString() + "C" + speciesColumn.toString() ).getValue();
  const nicknameValue = boxSheet.getRange("R" + rowIndex.toString() + "C" + nicknameColumn.toString() ).getValue();
  return ( "" != speciesValue || "" != nicknameValue )
}


/**
 * Given a row index, and access to the boxArray global variable; writes either a Box Header
 * or a Pokemon slot to the specified row.
 */
function formatBoxRow(rowIndex = 15, formatAsHeader = false, boxIsOdd = true, boxObject = null) {
  const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
  const boxColumnHeaders = sheetRef.getRangeByName("BoxesHeaderRow").getValues();
  const relevantRowFormatting = formatAsHeader ? headerRowFormatting : pokemonRowFormatting;
  const rowFormatKeys = Object.keys(relevantRowFormatting);
  const maxColumns = boxSheet.getMaxColumns();
  const useAlternatingBoxStyles = sheetRef.getRangeByName("OptionsUseAlternatingBoxColors").getValue();
  const boxDefaultStylesRange = (!boxIsOdd && useAlternatingBoxStyles) ? 
            sheetRef.getRangeByName("OptionsBoxEvenStyles") :
            sheetRef.getRangeByName("OptionsBoxOddStyles");
  // Format the row with the default style for the row type.
  const rowRange = boxSheet.getRange("A" + rowIndex.toString() + ":" + rowIndex.toString());
  const styleSubRangeDisplay = formatAsHeader ? boxDefaultStylesRange.getCell(1, 1) : boxDefaultStylesRange.getCell(2, 1);
  const styleSubRangeInput = formatAsHeader ? boxDefaultStylesRange.getCell(1, 1) : boxDefaultStylesRange.getCell(3, 1);
  styleSubRangeDisplay.copyTo(rowRange, {formatOnly:true})
  for (let col = 1; col <= maxColumns; col++) {
    const columnHeaderValue = boxColumnHeaders[0][col - 1]; // Get the current Column's name. Remember, getValues() returns a 2D Array.
    const cellAddress = "R" + col.toString() + "C" + rowIndex.toString();
    const currentCell = boxSheet.getRange(rowIndex, col);
    Logger.log("Cell " + cellAddress + ", Type: " + columnHeaderValue)
    // Skip populating the cell further, if the Column name isn't in the row format data.
    if ( !rowFormatKeys.includes(columnHeaderValue) ) { continue; } 
    const cellFormat = relevantRowFormatting[columnHeaderValue]; // Get the format object for that cell's column.
    const cellFormatKeys = Object.keys(cellFormat);
    // Populate the cell with appropriate content; as prescribed by the cell format data.
    if ( cellFormatKeys.includes('content')) {
      Logger.log("    Apply content " + cellFormat['content']);
      const speciesCell = columnToLetter(speciesColumn) + rowIndex.toString();
      let formulaString = "";
      switch ( cellFormat['content'] ) {
        case "Input Field": // do things depending on the key. obviously bold would just be an if, not a switch, but w/e
          styleSubRangeInput.copyTo(currentCell, {formatOnly:true})
          break;
        case "Image Formula":
          formulaString = '=IF(ISERROR(MATCH(' + speciesCell + ',ImportedPokemonNames,0)),"",';
          formulaString += 'IMAGE(SUBSTITUTE(Options!$C$29,"POKEMON",REGEXREPLACE(REGEXREPLACE(LOWER(' + speciesCell + ')," ","-"),"[:'
          formulaString += "'"; // we have to change to "" string wrappers to add a ' character.
          formulaString += '.%]","")),3))'
          currentCell.setFormula( formulaString );
          break;
        case "Level Formula":
          const expString = columnToLetter(expColumn) + rowIndex.toString();
          formulaString = '=IF(' + speciesCell + '="","",\n'
          formulaString += 'IF(' + expString + '<1,0,\n'
          formulaString += 'IF(' + expString + '<10,1,\n'
          formulaString += 'IF(' + expString + '<30,2,\n'
          formulaString += 'IF(' + expString + '<70,3,4)))))'
          currentCell.setFormula( formulaString );
          break;
        case "Types Formula":
          formulaString = '=IF(' + speciesCell + '<>"",\n';
          formulaString += 'SPLIT(VLOOKUP(' + speciesCell + ',ImportedPokemonData,2,FALSE),"/",FALSE,TRUE),'
          formulaString += '"")'
          currentCell.setFormula( formulaString );
          break;
        case "Species Data Formula":
          formulaString = '=IF(' + speciesCell + '<>"",{\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,3,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,4,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,5,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,6,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,7,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,8,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,9,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,10,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,11,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,12,FALSE),\n';
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,14,FALSE)\n';
          formulaString += '},"")'
          currentCell.setFormula( formulaString );
          break;
        case "Discipline Slot Formula":
          const levelString = columnToLetter(levelColumn) + rowIndex.toString();
          formulaString = '=MAX(0,' + levelString + '-1)'
          currentCell.setFormula( formulaString );
          break;
        case "Combo Slot Formula":
          formulaString = '=COMBOSLOTS(' + speciesCell + ',' + columnToLetter(levelColumn) + rowIndex.toString() + ')';
          currentCell.setFormula( formulaString );
          break;
        case "Moves Formula":
          formulaString = 'IF(' + speciesCell + '<>"",{\n'
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonData,13,FALSE),\n'
          formulaString += 'VLOOKUP(' + speciesCell + ',ImportedPokemonMovepoolData,3,FALSE),\n'
          formulaString += 'IF(E' + rowIndex.toString() + '>0,VLOOKUP(' + speciesCell + ',ImportedPokemonMovepoolData,4,FALSE),""),\n'
          formulaString += 'IF(E' + rowIndex.toString() + '>1,VLOOKUP(' + speciesCell + ',ImportedPokemonMovepoolData,5,FALSE),""),\n'
          formulaString += 'IF(E' + rowIndex.toString() + '>2,VLOOKUP(' + speciesCell + ',ImportedPokemonMovepoolData,6,FALSE),""),\n'
          formulaString += 'IF(E' + rowIndex.toString() + '>3,VLOOKUP(' + speciesCell + ',ImportedPokemonMovepoolData,7,FALSE),"")\n'
          formulaString += '},"")'
          currentCell.setFormula( formulaString );
          break;
        // Cases for Box Headers.
        case "Box Phrase":
          currentCell.setValue(getBoxPhrase());
          break;
        case "Box Name":
          if (boxObject && boxObject["name"]) { currentCell.setValue(boxObject["name"]) }
          break;
        case "Icon Preview Formula":
          if (boxObject && boxObject["startRow"] < boxObject["endRow"]) {
            currentCell.setFormula( "A" + (rowIndex + 1).toString() );
          }
          break;
        case "Box Preview/Toggle Formula":
          if (boxObject && boxObject["startRow"] < boxObject["endRow"]) {
            const startAddress = (boxObject["startRow"] + 1).toString();
            const endAddress = boxObject["endRow"].toString();
            formulaString = '=TOGGLEVISIBLE(A' + rowIndex.toString() + ',"BoxesBox' + boxObject["index"].toString() + '","Boxes",';
            formulaString += 'ROW(D' + startAddress + '),Row(D' + endAddress + '),TRUE,';
            formulaString += 'COUNTA(B' + startAddress + ':B' + endAddress + ')&" Pokemon, needing "&';
            formulaString += 'COUNTA(D' + startAddress + ':D' + endAddress + ')*70-';
            formulaString += 'SUM(D' + startAddress + ':D' + endAddress + ')&" EXP.", ';
            formulaString += 'JOIN(", ",SPLIT(JOIN(", ",B' + startAddress + ':B' + endAddress + '),", ",FALSE,TRUE)))'
            currentCell.setFormula( formulaString );
          }
          break;
        default:
          break;
      }
    }
    // Style the cell, based on the cellFormat.
    if ( cellFormatKeys.includes('bold') ) { currentCell.setFontWeight( cellFormat['bold'] ? "bold" : "normal"); }
    if ( cellFormatKeys.includes('italic') ) { currentCell.setFontStyle( cellFormat['italic'] ? "italic" : "normal"); }
    if ( cellFormatKeys.includes('fontFamily') ) { currentCell.setFontFamily( cellFormat['fontFamily']); }
    if ( cellFormatKeys.includes('fontSize') ) { currentCell.setFontSize( cellFormat['fontSize']); }
    if ( cellFormatKeys.includes('fontColor') ) { currentCell.setFontColor( cellFormat['fontColor']); }
    if ( cellFormatKeys.includes('align') ) { currentCell.setHorizontalAlignment( cellFormat['align'] ) }
    if ( cellFormatKeys.includes('cellBGColor') ) { currentCell.setBackground( cellFormat['cellBGColor'] ) }
    if ( cellFormatKeys.includes('wrapStrategy') ) {
      if ( 'wrap' == cellFormat['wrapStrategy'] ) { currentCell.setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP); }
      if ( 'overflow' == cellFormat['wrapStrategy'] ) { currentCell.setWrapStrategy(SpreadsheetApp.WrapStrategy.OVERFLOW); }
      if ( 'clip' == cellFormat['wrapStrategy'] ) { currentCell.setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP); }
    } else {
      currentCell.setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP);
    }
    
    // Style the cell's borders, if any.
    if ( cellFormatKeys.includes('borderTopColor') ) {
      currentCell.setBorder(true, null, null, null, null, null, cellFormat['borderTopColor'], SpreadsheetApp.BorderStyle.SOLID)
    }
    if ( cellFormatKeys.includes('borderLeftColor') ) {
      currentCell.setBorder(null, true, null, null, null, null, cellFormat['borderLeftColor'], SpreadsheetApp.BorderStyle.SOLID)
    }
    if ( cellFormatKeys.includes('borderBottomColor') ) {
      currentCell.setBorder(null, null, true, null, null, null, cellFormat['borderBottomColor'], SpreadsheetApp.BorderStyle.SOLID)
    }
    if ( cellFormatKeys.includes('borderRightColor') ) {
      currentCell.setBorder(null, null, null, true, null, null, cellFormat['borderRightColor'], SpreadsheetApp.BorderStyle.SOLID)
    }
    if ( cellFormatKeys.includes('dataRule') ) { 
      if ( "Checkbox" == cellFormat['dataRule'] ) {
        const rule = SpreadsheetApp.newDataValidation().requireCheckbox();
        currentCell.setDataValidation(rule)
      } else {
        const ruleName = "OptionsValidationRule" + cellFormat['dataRule'];
        Logger.log("    Apply " + ruleName + " to " + cellAddress);
        const sourceDataRuleCell = sheetRef.getRangeByName( ruleName );
        sourceDataRuleCell.copyTo(currentCell, SpreadsheetApp.CopyPasteType.PASTE_DATA_VALIDATION, false);
      }
      // If the current Cell has a data rule, and the format says to share the data rule with the next cell; do so.
      if ( cellFormat['applyRuleToNeighbor'] && col < maxColumns) {
        const neighbor = boxSheet.getRange(rowIndex, col + 1)
        currentCell.copyTo(neighbor, SpreadsheetApp.CopyPasteType.PASTE_DATA_VALIDATION, false);
      }
    } else {
      currentCell.setDataValidation(null);
    }
    
  } 
}


/**
 * Clears the global list of Boxes, and freshly populates it by reading the rows of the Box page.
 * 
 * By obtaining a fresh list of Boxes, any functions that rely on such a list can be made
 * resiliant to the player's possible direct edits to the Box page. This is why we don't rely
 * on making each Box into a NamedRange--they would fall apart when edited directly.
 */
function redefineAllBoxes() {
  const searchSheet = sheetRef.getSheetByName(pokemonListSheetName);
  const rowCount = searchSheet.getMaxRows();
  const boxPhrase = getBoxPhrase(); // We don't want the Box Phrase to be changed while the function is running.
  let boxArray = [] // Clear the Box array before searching.
  let currentBox = null;
  // TODO:
  //    Get the keys of the box-formatting const, and store "expColumn" etc. by checking for names in there.
  //    "buildPokemonRowValues", above, allows this.
  // Get the starting row of each Box, and with this, the total number of Boxes.
  for( var i = headerRowCount + 1; i <= rowCount; i++) {
    const checkedCell = sheetRef.getRange("R"+i.toString()+"C"+speciesColumn.toString());
    
    if ( checkedCell.getValue() == boxPhrase ) {
      let newBox = {
        "name": sheetRef.getRange("R"+i.toString()+"C"+nicknameColumn.toString()).getValue(),
        "index": boxArray.length,
        "startRow":i,
        "expNeeded":0
      };
      boxArray.push(newBox);
      currentBox = newBox;
    } else if ( currentBox ) {
      const expNeeded = 70 - sheetRef.getRange("R"+i.toString()+"C"+expColumn.toString()).getValue();
      currentBox["expNeeded"] += expNeeded;
    }
  }
  // Iterate through the new Boxes.
  // Set the ending row of each Box to just preceed the start of the next Box.
  for ( var i = 0; i < boxArray.length; i++) {
    boxArray[i]["isOdd"] = 0 == (i % 2)
    if ( boxArray.length - 1 == i ) {
      boxArray[i]["endRow"] = rowCount;
    } else {
      boxArray[i]["endRow"] = boxArray[ i+1 ]["startRow"] -1
    }
    Logger.log(JSON.stringify(boxArray[i]));
  }
  //Browser.msgBox( "Built Boxes:/n/n" + JSON.stringify(boxArray) );
  return boxArray;
}


/**
 * Re-Formats the header row of every Box.
 */
function formatAllBoxHeaders() {
  const boxArray = redefineAllBoxes();
  for ( let i = 0; i < boxArray.length; i++) {
    const box = boxArray[i];
    Logger.log("Reformatting header of " + box["name"]);
    formatBoxRow(box["startRow"], true, box["isOdd"], box);
  }
}


/**
 * Creates a new empty box, with 
 */
function newEmptyBox() {
  ui = SpreadsheetApp.getUi();
  const response = ui.alert(
    "New Empty Box", 
    "Creates a new Box with five pre-formatted empty rows, ready to be populated with Pokemon, at the end of your Boxes page.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxArray = redefineAllBoxes();
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    let newBox = {
      "name": "Box " + (boxArray.length + 1).toString(),
      "startRow": boxSheet.getMaxRows() + 1,
      "endRow": boxSheet.getMaxRows() + 1 + 5,
      "isOdd": 0 == boxArray.length % 2,
      "index":boxArray.length,
      "expNeeded":0
    }
    
    boxSheet.insertRowsAfter(boxSheet.getMaxRows(), 6)
    for( let i = newBox["startRow"]; i <= newBox["endRow"]; i++) {
      formatBoxRow(i, i == newBox["startRow"], newBox["isOdd"], newBox)
    }
  }
}


/**
 * By setting each Box's visibility checkbox to FALSE, hides each Box in the sheet.
 */
function foldAllBoxes() {
  ui = SpreadsheetApp.getUi();
  const response = ui.alert(
    "Fold All Boxes", 
    "Close all Boxes, leaving only their headers visible.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxArray = redefineAllBoxes();
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    for (let i = 0; i < boxArray.length; i++) {
      const box = boxArray[i];
      boxSheet.getRange("A" + box["startRow"].toString() ).setValue(false);
      if (box["endRow"] > box["startRow"]) {
        boxSheet.hideRows(box["startRow"] + 1, box["endRow"] - box["startRow"] )
      }
    }
  }
}


function newBoxLoosePokemon() {
  ui = SpreadsheetApp.getUi();
  ui.alert(
    "New Box for Loose Pokemon", 
    "Creates a new Box at the end of your Boxes page; then moves all un-Boxed Pokemon to the new Box."+
    "\n\nThen, adds two empty rows to the end of the new Box, ready to be populated with Pokemon.",
    Browser.Buttons.YES_NO)
    // TODO: "There are no loose Pokemon. Create a new empty Box instead?"
    // TODO: Count the number of loose Pokemon not found in a Box.
    // TODO: Create a new box, with a number of rows equal to the number of loose Pokemon plus 2.
}


/**
 * Sorts boxes by the user's specified box-sorting criteria, specified on the Options page.
 * 
 * I hate this function. 9999 hours wasted chasing undefined "Cannot access sheet at id" errors,
 * when it was just trying to move rows out of bounds. google sheets causes great damage to the
 * human spirit without a single benefit
 */
function sortBoxes() {
  // Obtain the Boxes sheet.
  const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
  SpreadsheetApp.setActiveSheet(boxSheet);
  // Get all Boxes. If none exist, ask the player if they would like to create one, instead of sorting.
  ui = SpreadsheetApp.getUi();
  const boxArray = redefineAllBoxes();
  if ( 1 > boxArray.length ) {
    ui.alert(
      "No Boxes.",
      "You have no Boxes to sort.",
      Browser.Buttons.OK)
    newEmptyBox();
    return;
  }
  const response = ui.alert(
    "Sort Boxes", 
    "Arranges your Boxes, according to your Box Sort setting in the Options page."+
    "\n\n"+
    "The order of Pokemon within each Box will remain unchanged.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    Logger.log('Start sorting Boxes by defining said Boxes.');
    const boxArray = redefineAllBoxes();
    const boxSortOptions = sheetRef.getRangeByName("OptionsBoxSort").getValues();
    // Extract this range and use it to set the sorting function... Once boxes stop going out of bounds.
    //ui.alert(JSON.stringify(boxSortOptions), Browser.Buttons.OK);
    // Store the "target" row, that boxes will be sent to as they are sorted.
    // The initial value is the number of header rows, plus one to obtain the first box row.
    // As we send boxes to this row, update this target to the new end of the unsorted rows.
    let targetRow = headerRowCount + 1;
    //TODO: find the sort function to use.
    //const sortedBoxArray = boxArray.sort( (a, b) => b["name"].localeCompare(a["name"]) ); // Descending Name.
    let sortedBoxArray = JSON.parse(JSON.stringify(boxArray.sort( (a, b) => a["name"].localeCompare(b["name"]) ))); // Ascending Name.
    Logger.log("Sorted Box Array: " + JSON.stringify(sortedBoxArray));
    // Iterate through boxes, except the last box.
    // By moving all other boxes, the last box will end up at their destination naturally.
    for (var i = 0; i < sortedBoxArray.length - 1; i++) { 
      const activeBox = sortedBoxArray[i];
      const boxRangeStart = activeBox["startRow"];
      const boxRangeEnd = activeBox["endRow"];
      const boxSize = boxRangeEnd - boxRangeStart + 1;
      Logger.log( "-> Move " + activeBox["name"] + " from rows " + boxRangeStart.toString() + "-" + boxRangeEnd.toString() +
                                                      " to rows " + targetRow.toString() ) + "-" + (targetRow + boxSize - 1).toString();
      // Check if box needs to move at all.
      if ( targetRow < boxRangeStart || targetRow > boxRangeEnd ) { 
        // Move the box to  the beginning of the box list.
        const boxRangeString = "A" + boxRangeStart.toString() + ":A" + boxRangeEnd.toString();
        boxSheet.moveRows( boxSheet.getRange(boxRangeString), targetRow )
      }
      // Increment the targetRow.
      targetRow += boxSize;
      // Check each box yet to be moved.
      for (var j = i; j < sortedBoxArray.length; j++) {
        // For each such box, that was between the current box's original position and its new position...
        if ( sortedBoxArray[j]["endRow"] <= boxRangeEnd ) {
          // Update that box-yet-to-be-moved's position, for when it is later moved.
          sortedBoxArray[j]["startRow"] += boxSize;
          sortedBoxArray[j]["endRow"] += boxSize;
        }
        Logger.log("Updated: " + JSON.stringify( sortedBoxArray[j] ));
      }
    }
  } else {
    Logger.log('Dialogue dismissed.');
  }
}


/**
 * Within each box left *wholly* visible by the user, sorts the Pokemon within.
 */
function sortPokemonWithinVisibleBoxes() {
  // Obtain the Boxes sheet.
  const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
  SpreadsheetApp.setActiveSheet(boxSheet);
  SpreadsheetApp.flush(); // This applies the pending "Set Active Sheet" command.
  const newSheetRef = SpreadsheetApp.getActiveSpreadsheet();
  // Get all Boxes. If none exist, ask the player if they would like to create one, instead of sorting.
  ui = SpreadsheetApp.getUi();
  const boxArray = redefineAllBoxes();
  if ( 1 > boxArray.length ) {
    ui.alert(
      "No Boxes.",
      "You have no Boxes to sort.",
      Browser.Buttons.OK)
    newEmptyBox();
    return;
  }
  // Unpack the user's Pokemon-sorting options.
  const boxSortOptions = newSheetRef.getRangeByName("OptionsPokemonSort").getValues();
  const boxSortCriteria = boxSortOptions[0][0];
  const boxSortDirection = boxSortOptions[1][0];
  // Ask for player confidence before proceeding.
  Logger.log("Obtaining user response...");
  const response = ui.alert(
    "Sort Pokemon Within Boxes", 
    "Sorts your Pokemon within each Box, based on your chosen criteria:" +
    "\n● By " + boxSortCriteria + ", " + boxSortDirection + "." + 
    "\n\n"+
    "Pokemon won't be moved to other Boxes. Boxes with any hidden rows will be skipped. Pokemon outside of Boxes will also be skipped.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    Logger.log('Start sorting Pokemon within Boxes, by defining said Boxes.');
    const headerArray = Object.keys(pokemonRowFormatting); // Store the expected header names for columns in Boxes.

    let sortColumnIndex = 0;
    let sortCriteriaFunction = function(a) { // The function to apply to the values going into the sort.
      return Number.isInteger(a) ? a.toString().padStart(10, "0") : a;
    }
    const sortMethodFunction = function(a, b) { // yeah man let's just store a big multiline 
      if ("" == b && "" != a) { return -1 }
      if ("" == a && "" != b) { return 1 }  // Sort empty values to the end of the box.
      return "Ascending" == boxSortDirection ? a.localeCompare(b) : b.localeCompare(a);
    }
    // The array of imported names is already sorted by Pokedex Number.
    // So, we borrow that array if we're sorting Pokemon by that criteria.
    let speciesNames = [];
    if ( "Pokedex Number" == boxSortCriteria ) {
      speciesNames = newSheetRef.getRangeByName("ImportedPokemonNames");
    }
    switch( boxSortCriteria ) {
      case "Species Name":
        sortColumnIndex = headerArray.indexOf("Species") - 1;
        break;
      case "Nickname":
        sortColumnIndex = headerArray.indexOf("Nickname") - 1;
        break;
      case "Pokedex Number":
        // We have to do some kind of evil shit for this one?
        break;
      case "Level":
        sortColumnIndex = headerArray.indexOf("Level") - 1;
        break;
      case "Total EXP":
        sortColumnIndex = headerArray.indexOf("Species") - 1;
        // We also have to do evil shit for this one...
        break;
      case "Total Missing EXP":
        sortColumnIndex = headerArray.indexOf("Species") - 1;
        break;
      case "Number of Notes":
        sortColumnIndex = headerArray.indexOf("Species") - 1;
        break;
      default:
        break;
    }
    // After setting up the function that will be used to sort rows, obtain the rows
    // of Pokemon data from each box, apply the stored function, and store the rows.
    for (let i = 0; i < boxArray.length; i++) {
      const currentBox = boxArray[i];
      const startRow = 1 + currentBox["startRow"]
      const endRow = currentBox["endRow"]
      const numberOfPokemonRows = currentBox["endRow"] - currentBox["startRow"];
      // If the box has less than 2 Pokemon Rows, skip the box.
      if ( numberOfPokemonRows < 2 ) { continue; }
      Logger.log("Sorting Pokemon in: " + currentBox["name"]);
      let rangeAddress = "R" + startRow.toString() + "C2:R" + endRow.toString()
      
      // Store the contents of the box's rows in a 2D array.
      Logger.log("    Accessing: " + rangeAddress);
      let dataRows = newSheetRef.getRange(rangeAddress).getValues();
      // Append the width of a row to the address, to avoid out-of-bounds access.
      Logger.log(JSON.stringify(dataRows.map( (x) => x[0])));
      // Sort the rows, using the stored functions. Then, finally, apply the sorted rows to the spreadsheet.
      dataRows.sort( (a, b) => sortMethodFunction( sortCriteriaFunction(a[sortColumnIndex]), sortCriteriaFunction(b[sortColumnIndex]) ));  
      rangeAddress += "C" + (dataRows[0].length + 1);
      Logger.log("    Saving to: " + rangeAddress);
      Logger.log(JSON.stringify(dataRows.map( (x) => x[0])));
      newSheetRef.getRange(rangeAddress).setValues(dataRows);
    }
  } else {
    Logger.log('Dialogue dismissed.');
  }
}


/**
 * User-facing function for cleaning up all Box headers.
 */
function requestFormatAllBoxHeaders() {
  // Get all Boxes. If none exist, ask the player if they would like to create one, instead of formatting headers.
  ui = SpreadsheetApp.getUi();
  const boxArray = redefineAllBoxes();
  if ( 1 > boxArray.length ) {
    ui.alert(
      "No Boxes.",
      "You have no Boxes to sort.",
      Browser.Buttons.OK)
    newEmptyBox();
    return;
  }
  
  const response = ui.alert(
    "Format All Box Headers", 
    "Re-Formats the header row of each Box, to correct the Box's name, visibility-toggling formula, and affected row indeces.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    // We already have this function. :)
    formatAllBoxHeaders();
  }
}


function explodeAllBoxes() {
  // Get all Boxes. If none exist, ask the player if they would like to create one, instead of formatting headers.
  ui = SpreadsheetApp.getUi();
  const boxArray = redefineAllBoxes();
  if ( 1 > boxArray.length ) {
    ui.alert(
      "No Boxes.",
      "You have no Boxes to explode.",
      Browser.Buttons.OK)
    newEmptyBox();
    return;
  }
  
  const response = ui.alert(
    "Explode All Boxes", 
    "Deletes all Box headers, stranding all Pokemon contained within in an unsorted pile.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    // We're deleting rows, so we iterate through Boxes backwards.
    for (let i = boxArray.length - 1; i >= 0; i--) {
      boxSheet.deleteRow(boxArray[i]["startRow"]);
    }
  }
}
function deleteEmptyBoxes() {
  // Get all Boxes. If none exist, ask the player if they would like to create one, instead of formatting headers.
  ui = SpreadsheetApp.getUi();
  const boxArray = redefineAllBoxes();
  if ( 1 > boxArray.length ) {
    ui.alert(
      "No Boxes.",
      "You have no Boxes to search.",
      Browser.Buttons.OK)
    newEmptyBox();
    return;
  }
  
  const response = ui.alert(
    "Delete All Empty Boxes", 
    "Deletes every Box that contains no values in its Species Column.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    // We're deleting rows, so we iterate through Boxes backwards.
    for (let i = boxArray.length - 1; i >= 0; i--) {
      const box = boxArray[i];
      let pokemonFound = false;
      for (let row = box["startRow"]; row <= box["endRow"]; row++) {
        if (pokemonFound || row == box["startRow"]) { continue; } // safest way to skip the starting row.
        const searchAddress = "R" + row.toString() + "C" + speciesColumn.toString();
        if ("" != boxSheet.getRange(row, speciesColumn).getValue() ) { pokemonFound = true; } // accept anything
      }
      // If no Pokemon were found in the Box, delete the Box.
      if ( !pokemonFound ) { 
        boxSheet.deleteRows(box["startRow"], box["endRow"] - box["startRow"] + 1); 
      }
    }
  }
}


/**
 * Cleans all extant Boxes of any unused rows, and then addeds three new rows to 
 * the end of each Box, pre-formatted and ready to hold Pokemon.
 */
function formatAllPokemonBoxes() {
  ui = SpreadsheetApp.getUi();
  const numberOfNewEmptyRows = 3;
  const response = ui.alert(
    "Format All Boxes", 
    "Within each Box, copy the formatting of the first Pokemon row within to each other row in that Box."+
    "If the Box has no Pokemon Rows, default formatting is used instead."+
    "\n\n"+
    "Then, delete all rows lacking a Species or Nickname from all Boxes."+
    "\n\n"+
    "Lastly, add " + numberOfNewEmptyRows.toString() +  " empty rows to the end of each Box to receive new Pokemon.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxArray = redefineAllBoxes();
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    // Iterate backwards through the boxArray, so that edits made when formatting 
    // later boxes don't change the row indexes of earlier boxes not yet formatted.
    for ( let boxIndex = boxArray.length - 1; boxIndex >= 0; boxIndex--) {
      const box = boxArray[boxIndex];
      
      // Store which rows within the current box contain no Species or Nickname, for later deletion.
      let emptyRowIndices = [];
      // Add rows to the end of the box to hold new Pokemon, until the box has three empty rows.
      boxSheet.insertRowsAfter(box["endRow"], numberOfNewEmptyRows)
      // If the box had no Pokemon rows, then the new empty rows need to be formatted with default formatting.
      if ( box["startRow"] == box["endRow"] ) {
        for (let i = box["endRow"] + 1; i <= box["endRow"] + numberOfNewEmptyRows; i++) {
          formatBoxRow(i, i == box["startRow"], box["isOdd"])
        }
      } else { // Otherwise, copy the formatting of the first Pokemon row in the box to the other Pokemon rows.
        const rowIndex = box["startRow"] + 1;
        const templateRow = boxSheet.getRange("A" + rowIndex.toString() + ":" + rowIndex.toString());
        const targetRange = boxSheet.getRange("A" + (rowIndex + 1).toString() + ":" + (box["endRow"] + numberOfNewEmptyRows).toString() )
        templateRow.copyTo(targetRange, {formatOnly:true})
      }
      //formatBoxRow(i, i == box["startRow"], box["isOdd"])
      // Format each row in the box.
      for( let i = box["startRow"]; i <= box["endRow"]; i++) {
        // If the row is unused, mark it for deletion.
        if ( !rowIsUsed(i) ) {
          emptyRowIndices.push(i);
        }
      }
      // Delete all discovered unused rows in the Box.
      // The newly-added empty rows already exist beyond the box's old endRow.
      // Because we are deleting rows, we iterate backwards.
      for( let i = emptyRowIndices.length - 1; i >= 0; i--) {
        boxSheet.deleteRow( emptyRowIndices[i] )
      }
    }
    // After reformatting all Boxes; re-populate each Box header.
    formatAllBoxHeaders();
  }
}


/**
 * Cleans all rows, whether they're Pokemon or Box headers.
 */
function formatAllBoxAndPokemonRows() {
  ui = SpreadsheetApp.getUi();
  const response = ui.alert(
    "Format All Box and Pokemon Rows", 
    "Destructively formats every row in the Boxes sheet using default formatting, leaving only input fields intact."+
    "\n\n"+
    "All custom colors, formulas, etc. are at risk. This function can be slow.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxArray = redefineAllBoxes();
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    for ( let i = 0; i < boxArray.length; i++ ) {
      const box = boxArray[i];
      Logger.log("Cleaning formatting in Box: " + box["name"]);
      for( let row = box["startRow"]; row <= box["endRow"]; row++) {
        formatBoxRow(row, row == box["startRow"], box["isOdd"], box);
      }
    }
  }
}


/**
 * Reveals all hidden rows and columns in the Boxes sheet.
 */
function forceDisplayBoxRowsColumns() {
  ui = SpreadsheetApp.getUi();
  const response = ui.alert(
    "Forcibly unhide all Box rows and columns", 
    "Reveal all hidden rows and hidden columns in the Boxes sheet."+
    "\n\n"+
    "Primarily useful for revealing stranded box rows.",
    Browser.Buttons.YES_NO)
  if (response == ui.Button.YES) {
    const boxSheet = sheetRef.getSheetByName(pokemonListSheetName);
    sheetRef.unhideColumn(boxSheet.getRange("1:1"));
    sheetRef.unhideRow(boxSheet.getRange("A:A"));
  }
}


/** 
 * Displays a report of the total number of Boxes, Pokemon, and more.
 */
function displayBoxReport() {
  Browser.msgBox('displayBoxReport.');
}
[code]
[/hide]
 
Box Parameters.gs
JavaScript:
/**
 * A global variable containing formatting for each column in the Boxes rows, for Pokemon.
 *
 * If you alter your Boxes sheet, you can ensure that the Wizard Menu correctly styles your
 * arrangement of columns by editing this object.
 *
 * You don't have to make this object match the *order* of your columns; but it does need to
 * match the *names* in your column header: Icon, Species, Nickname, and so on.
 * If you change the header names, make sure to change the matching object keys here.
 *
 * If you need to, ask for help from the Union Street thread on Smogon, or in the BBP Discord.
 *
 * You can also simply edit this object to cause the Wizard Menu to style cells how you like.
 */
const pokemonRowFormatting = {
  "Icon": { 'content':"Image Formula" }, // each cell gets an object describing how to style and populate that cell.
  "Species": { 'content':"Input Field", 'italic':true, 'align':'left' },
  "Nickname": { 'content':"Input Field", 'bold':true, 'align':'left' },
  "EXP": { 'content':"Input Field", 'dataRule':"EXP" },
  "Level": { 'content':"Level Formula", 'dataRule':"Level" },
  "Types": { 'content':"Types Formula", 'dataRule':"TypeDisplay", 'applyRuleToNeighbor':true },
  "blank_Types": {  }, // Part of the merged "Types" cell.
  "Abilities": { 'content':"Species Data Formula", "fontSize":10 },
  "Hidden Ability": { 'italic':true, "fontSize":10 },
  "HP":  { 'fontColor':"#38761C", 'bold':true },
  "Atk": { 'fontColor':"#7F5F00", 'bold':true },
  "Def": { 'fontColor':"#990000", 'bold':true },
  "SpA": { 'fontColor':"#0F55CC", 'bold':true },
  "SpD": { 'fontColor':"#674EA7", 'bold':true },
  "Spe": { 'fontColor':"#741A47", 'bold':true },
  "SC":  { 'italic':true },
  "WC":  { 'italic':true },
  "Trait": { 'fontSize':10, 'align':'left' },
  "D.Slots": { 'content':"Discipline Slot Formula" },
  "Lv2 Discipline": { 'content':"Input Field", 'dataRule':"DisciplineLv2" },
  "Lv3 Discipline": { 'content':"Input Field", 'dataRule':"DisciplineLv3" },
  "Lv4 Discipline": { 'content':"Input Field", 'dataRule':"DisciplineLv4" },
  "Combo Slots": { 'content':"Combo Slot Formula" },
  "Combo List": { 'content':"Input Field" },
  "ME": { 'content':"Input Field", 'fontColor':"#38761C", 'dataRule':"Checkbox" },
  "ZM": { 'content':"Input Field", 'fontColor':"#BF9000", 'dataRule':"Checkbox" },
  "DY": { 'content':"Input Field", 'fontColor':"#CC0000", 'dataRule':"Checkbox" },
  "TE": { 'content':"Input Field", 'fontColor':"#0F55CC", 'dataRule':"Checkbox" },
  "3C": { 'content':"Input Field", 'fontColor':"#741A47", 'dataRule':"Checkbox" },
  "Hidden Type": { 'content':"Input Field", 'dataRule':"TypeSelector" },
  "Tera Type":   { 'content':"Input Field", 'dataRule':"TypeSelector" },
  "Added Moves": { 'content':"Input Field" },
  "Signature Moves": { 'content':"Moves Formula", 'italic':true, "fontSize":10 },
  "Lv0 Moves": { 'italic':true, "fontSize":9 },
  "Lv1 Moves": { 'italic':true, "fontSize":9 },
  "Lv2 Moves": { 'italic':true, "fontSize":9 },
  "Lv3 Moves": { 'italic':true, "fontSize":9 },
  "Lv4 Moves": { 'italic':true, "fontSize":9 },
  "Note 1": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#E06665", "align":'left' },
  "Note 2": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 3": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 4": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 5": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 6": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 7": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 8": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
  "Note 9": { 'cellBGColor':"#F3F3F3", "borderBottomColor":"#9EC5E9", "borderRightColor":"#9EC5E9", "align":'left' },
}
const headerRowFormatting = {
  "Icon": { 'dataRule':"Checkbox" }, // each cell gets an object describing how to style and populate that cell.
  "Species": { 'content':"Box Phrase" },
  "Nickname": { 'content':"Box Name", 'fontFamily':"Cambria" },
  "EXP": { 'content':"Icon Preview Formula" },
  "Level": { 'content':"Box Preview/Toggle Formula", 'italic':true, 'align':'left', 'wrapStrategy':'overflow', 'fontFamily':"Nunito", 'fontSize':11 }
}

Profile Builder.gs
JavaScript:
/**
 * Provided a row of user data for a Pokemon (Nickname, EXP, etc) a row of species data
 * (types, abilities, etc), and a column of Profile Format data; outputs a forum-pasteable
 * Pokemon Profile with the input data arranged in the input Format.
 *
 * @param {Array} pokemonUserData The row of data to be fed to the generator.
 * @param {Array} pokemonSpeciesData The row of species data to be fed to the generator.
 * @param {Array} formesArray aaaa
 * @param {Array} signatureMovesArray bbb
 * @param {Array} profileFormat The column of Format data for the Pokemon to be parsed through.
 * @param {Array} pokemonDataTable The imported range containing Data of all Pokemon species.
 * @param {Array} disciplineDataTable The imported range containing Data of all Disciplines.
 *
 * @customfunction
 */
function GENERATEPROFILE(pokemonUserData, pokemonSpeciesData, formesArrayString, signatureMovesArray, profileFormat,
  pokemonDataTable, disciplineDataTable, techsDataTable) {
  // We reject the call if no data was provided to us.
  if ( !pokemonUserData ) {
    return "No user-provided Pokemon data."
  } else if ( !pokemonSpeciesData ) {
    return "Not an extant Pokemon species."
  } else if ( !profileFormat ) {
    return "No user-provided profile format data."
  }
 
  // Now the work can begin.
  // Arrange user-input data into an object.
  const pokemonIdentity = {
    "Species Name": pokemonUserData[0][0],
    "Nickname": pokemonUserData[0][1],
    "EXP": pokemonUserData[0][2],
    "Level": pokemonUserData[0][3],
    "Discipline Slots": pokemonUserData[0][17],
    "Discipline 1": pokemonUserData[0][18],
    "Discipline 2": pokemonUserData[0][19],
    "Discipline 3": pokemonUserData[0][20],
    "Combo Slots": pokemonUserData[0][21], // How am I splitting this??
    "Combo List": pokemonUserData[0][22], // How am I splitting this??
    "Tech Mega": pokemonUserData[0][23],
    "Tech ZMoves": pokemonUserData[0][24],
    "Tech Dynamax": pokemonUserData[0][25],
    "Tech Terastal": pokemonUserData[0][26],
    "Tech TripleCombo": pokemonUserData[0][27],
    "Hidden Power Type": pokemonUserData[0][28],
    "Tera Type": pokemonUserData[0][29],
    "Added Moves": pokemonUserData[0][30],
    "Signature Moves": pokemonUserData[0][31],
    "MovesLv0": pokemonUserData[0][32],
    "MovesLv1": pokemonUserData[0][33],
    "MovesLv2": pokemonUserData[0][34],
    "MovesLv3": pokemonUserData[0][35],
    "MovesLv4": pokemonUserData[0][36],
    "Note1": pokemonUserData[0][37],
    "Note2": pokemonUserData[0][38],
    "Note3": pokemonUserData[0][39],
    "Note4": pokemonUserData[0][40],
    "Note5": pokemonUserData[0][41],
    "Note6": pokemonUserData[0][42],
    "Note7": pokemonUserData[0][43],
    "Note8": pokemonUserData[0][44],
    "Note9": pokemonUserData[0][45]
  }
  // Arrange species data for the pokemon into an object.
  const pokemonSpecies = convertDataRowToObject(pokemonSpeciesData);
  // First, we update the pokemonIdentity with some derived figures:
  // We split the Pokemon's Combo List and Added Moves into arrays so they are easier to work with.
  pokemonIdentity["Combo Array"] = pokemonIdentity["Combo List"].split(String.fromCharCode(10)).join(",").split(",").map((str) => str.trim());
  pokemonIdentity["Added Move Array"] = pokemonIdentity["Added Moves"].split(String.fromCharCode(10)).join(",").split(",").map((str) => str.trim());
  // Remove all empty string entries.
  pokemonIdentity["Combo Array"] = pokemonIdentity["Combo Array"].filter(a => a !== "");
  pokemonIdentity["Added Move Array"] = pokemonIdentity["Added Move Array"].filter(a => a !== "");
  // Replace Discipline entries with Objects containing each Discipline's table data.
  pokemonDisciplines = [];
  const disciplineNames = disciplineDataTable.map( (row) => row[0] )
  for (var i = 1; i < 4; i++) {
    const disciplineName = pokemonIdentity["Discipline " + i.toString()];
    if ( disciplineName == "" ) { pokemonDisciplines.push(null); continue; }
    const disciplineIndex = disciplineNames.indexOf(disciplineName);
    if ( disciplineIndex < 0 ) { pokemonDisciplines.push(null); continue; }
    const disciplineRow = disciplineDataTable[ disciplineIndex ];
    const disciplineObject = {
      "Name": disciplineRow[0],
      "Level": disciplineRow[2], // Skip index 1. It's a hidden import row.
      "Summary": disciplineRow[3],
      "Flavor": disciplineRow[4],
      "Effects": disciplineRow[5],
      "Tags": disciplineRow[6]
    }
    pokemonDisciplines.push(disciplineObject)
  }
  pokemonIdentity["Disciplines"] = pokemonDisciplines;
  // The Signature Moves of the Pokemon if any, and their Levels, must be passed in manually.
  if ( signatureMovesArray ) {
    const signatureMoves = signatureMovesArray[0][0].split(", ");
    const signatureMoveLevels = signatureMovesArray[0][1].split(", ");
    for(var i = 0; i < signatureMoves.length; i++) {
      const movepoolLevel = "MovesLv" + signatureMoveLevels[i].toString();
      if ( pokemonIdentity[movepoolLevel].includes( signatureMoves[i] ) ) {
        pokemonIdentity[movepoolLevel] = pokemonIdentity[movepoolLevel].replace( signatureMoves[i], signatureMoves[i] + " SIGNATURE_INDICATOR" );
      } else {
        var subMovepool = pokemonIdentity[movepoolLevel].split(String.fromCharCode(10)); // e.g. The Pokemon's Lv3 Movepool
        subMovepool.push( signatureMoves[i] + " SIGNATURE_INDICATOR" );
        pokemonIdentity[movepoolLevel] = subMovepool.sort().join("\r");
      }
    }
  }
  // We touch up the Species Object with additional data, for the base Forme specifically.
  pokemonSpecies["Formes"] = formesArrayString ? formesArrayString.split(", ").map((item) => JSON.parse(item)) : [];
  const techsSpeciesNames = techsDataTable.map( (row) => row[0] )
  const speciesTechs = techsDataTable[techsSpeciesNames.indexOf(pokemonSpecies["Name"])]
  pokemonSpecies["Sig Z-Moves"] = speciesTechs ? speciesTechs[2] : "";
  pokemonSpecies["Sig G-Max Moves"] = speciesTechs ? speciesTechs[3] : "";
  // Then, we create a list of flags for what's true about the Pokemon.
  const profileCritera = generateProfileCritera(pokemonIdentity, pokemonSpecies)
  // Then, based on the pokemonFlags object we've built, we prepare the profile format:
  let profile = "";
  let formeBlockFlag = false; // toggled to TRUE when we hit StartFormes, and toggled to FALSE by EndFormes
  // Make an array of forme objects, storing the forme name and strings.
  let formes = [];
  for (var i = 0; i < pokemonSpecies["Formes"].length; i++) {
    const formeDataRow = pokemonDataTable[ pokemonSpecies["Formes"][i] - 1 ]; // Row indexes are off by one.
    const formeData = convertDataRowToObject([formeDataRow]);
    if ( !pokemonIdentity["Tech Mega"] &&  // IF mega locked AND forme is a -mega, -primal, or -ultra; skip that forme.
      ( formeData["Name"].indexOf("-Mega") != -1 ||
        formeData["Name"].indexOf("-Primal") != -1 ||
        formeData["Name"].indexOf("-Ultra") != -1)) {
      continue;
    }
    // If the forme is included, push the following forme object into the formes array.
    const formeProfileCritera = generateProfileCritera(pokemonIdentity, formeData);
    formes.push( {
      "formeName":formeData["Name"],
      "formeProfileString": "", // Start with an empty profile string for now.
      "formeSpeciesData": formeData,
      "profileCriteria": formeProfileCritera
    } );
  }
  for (const line of profileFormat) {
    if ("" == line[0]) { continue; } // we should do this again AFTER replacing text/applying criteria
    // Start or end the Forme Block based on specific metadata lines.
    if (formeBlockFlag && "EndFormes" == line[0]) {
      formeBlockFlag = false;
      // cap every formeprofile with a [td][/td]
      for (const forme of formes) {
        profile = profile.concat(
          (formes.length > 1) ? "[TD width=" + (10000 / formes.length * 0.01) + "%]" : "",
          replaceAllPlaceholders(forme.formeProfileString, pokemonIdentity, forme.formeSpeciesData),
          (formes.length > 1) ? "[/TD]" : ""
        )
      }
      if (formes.length > 1) { profile += "[/TR][TABLE]" }
      continue;
    } else if (!formeBlockFlag && "StartFormes" == line[0]) {
      formeBlockFlag = true;
      (formes.length == 0) ? profile += "" :
      (formes.length > 1) ? profile += "[TABLE][TR]" : profile += "";
      continue;
    }
    if (formeBlockFlag) {
      for (const forme of formes) {
        const trimmedLine = splitByCriteria(line[0], forme.profileCriteria);
        if ("" == trimmedLine) { continue; }
        if ( !trimmedLine.includes("LineJoin") &&  forme.formeProfileString != "") {  forme.formeProfileString += "\r"; }
        forme.formeProfileString += trimmedLine.replace("LineJoin", "");
      }
    } else {
      const trimmedLine = splitByCriteria(line[0], profileCritera)
      if ("" == trimmedLine) { continue; }
      if ( !trimmedLine.includes("LineJoin") && profile != "") { profile += "\r"; }
      profile += trimmedLine.replace("LineJoin", "");
    }
  }
  profile = replaceAllPlaceholders(profile, pokemonIdentity, pokemonSpecies);
  return profile;
}


/**
 * Returns an object consisting of TRUE or FALSE answers to various profile-generation queries.
 */
function generateProfileCritera(pokemonIdentity, speciesData) {
  return {
    "##": false, // Always return false. Used for commenting.
    "Comment:": false,
    "IfNickname:": (pokemonIdentity["Nickname"] != ""),
    "IfNoNickname:": (pokemonIdentity["Nickname"] == ""),
    "IfLevel1:": (pokemonIdentity["Level"] > 0),
    "IfLevel2:": (pokemonIdentity["Level"] > 1),
    "IfLevel3:": (pokemonIdentity["Level"] > 2),
    "IfLevel4:": (pokemonIdentity["Level"] > 3),
    "IfNotLevel1:": (pokemonIdentity["Level"] <= 0),
    "IfNotLevel2:": (pokemonIdentity["Level"] <= 1),
    "IfNotLevel3:": (pokemonIdentity["Level"] <= 2),
    "IfNotLevel4:": (pokemonIdentity["Level"] <= 3),
    "IfTrait:": (speciesData["Traits"] && "" != speciesData["Traits"]),
    "IfAnyDiscs:": (null == pokemonIdentity["Disciplines"][0] &&
                    null == pokemonIdentity["Disciplines"][1] &&
                    null == pokemonIdentity["Disciplines"][2]),
    "IfAnyDiscSlots:": (pokemonIdentity["Discipline Slots"] > 0),
    "IfDisc1:": (null != pokemonIdentity["Disciplines"][0]),
    "IfDisc2:": (null != pokemonIdentity["Disciplines"][1]),
    "IfDisc3:": (null != pokemonIdentity["Disciplines"][2]),
    "IfNoDisc1:": (null == pokemonIdentity["Disciplines"][0]),
    "IfNoDisc2:": (null == pokemonIdentity["Disciplines"][1]),
    "IfNoDisc3:": (null == pokemonIdentity["Disciplines"][2]),
    "IfAnyComboSlots:": (pokemonIdentity["Combo Slots"] > 0),
    "IfAnyCombosKnown:": (pokemonIdentity["Combo List"] != ""), // Ignorant check that doesn't account for whitespace or legality.
    "If1Combo:": (pokemonIdentity["Combo Slots"] > 0),
    "If2Combos:": (pokemonIdentity["Combo Slots"] > 1),
    "If3Combos:": (pokemonIdentity["Combo Slots"] > 2),
    "If4Combos:": (pokemonIdentity["Combo Slots"] > 3),
    "If5Combos:": (pokemonIdentity["Combo Slots"] > 4),
    "If6Combos:": (pokemonIdentity["Combo Slots"] > 5),
    "IfDualType:": (null != speciesData["Type2"]),
    "IfSecondAbil:": (speciesData["AbilityArray"].length > 1),
    "IfThirdAbil:": (speciesData["AbilityArray"].length > 2),
    "IfHiddenAbil:": (speciesData["HiddenAbilityArray"][0] != ""),
    "IfSecondHiddenAbil:": (speciesData["HiddenAbilityArray"].length > 1),
    "IfThirdHiddenAbil:": (speciesData["HiddenAbilityArray"].length > 2),
    "IfAnyTech:": ( pokemonIdentity["Tech Mega"] || pokemonIdentity["Tech ZMoves"] || pokemonIdentity["Tech Dynamax"] ||
                    pokemonIdentity["Tech Terastal"] || pokemonIdentity["Tech TripleCombo"] ),
    "IfMega:": ( pokemonIdentity["Tech Mega"] ),
    "IfZMove:": ( pokemonIdentity["Tech ZMoves"] ),
    "IfDyna:": ( pokemonIdentity["Tech Dynamax"] ),
    "IfTera:": ( pokemonIdentity["Tech Terastal"] ),
    "IfTriple:": ( pokemonIdentity["Tech TripleCombo"] ),
    "IfSigZ:": ( speciesData["Sig Z-Moves"] != "" ),
    "IfGmaxMove:": ( speciesData["Sig G-Max Moves"] != "" ),
    "IfAnyFormes:": (speciesData["Formes"].length > 0),
    "If2PlusFormes:": (speciesData["Formes"].length > 1),
    "IfNoFormes:": (speciesData["Formes"].length < 1),
    "If1Forme:": (speciesData["Formes"].length == 1),
    "If2Formes:": (speciesData["Formes"].length == 2),
    "If3Formes:": (speciesData["Formes"].length == 3),
    "If4Formes:": (speciesData["Formes"].length == 4),
    "If5Formes:": (speciesData["Formes"].length == 5),
    "If6Formes:": (speciesData["Formes"].length == 6),
    "IfNote1:": (pokemonIdentity["Note1"] != ""),
    "IfNote2:": (pokemonIdentity["Note2"] != ""),
    "IfNote3:": (pokemonIdentity["Note3"] != ""),
    "IfNote4:": (pokemonIdentity["Note4"] != ""),
    "IfNote5:": (pokemonIdentity["Note5"] != ""),
    "IfNote6:": (pokemonIdentity["Note6"] != ""),
    "IfNote7:": (pokemonIdentity["Note7"] != ""),
    "IfNote8:": (pokemonIdentity["Note8"] != ""),
    "IfNote9:": (pokemonIdentity["Note9"] != ""),
    "IfNoNote1:": (pokemonIdentity["Note1"] == ""),
    "IfNoNote2:": (pokemonIdentity["Note2"] == ""),
    "IfNoNote3:": (pokemonIdentity["Note3"] == ""),
    "IfNoNote4:": (pokemonIdentity["Note4"] == ""),
    "IfNoNote5:": (pokemonIdentity["Note5"] == ""),
    "IfNoNote6:": (pokemonIdentity["Note6"] == ""),
    "IfNoNote7:": (pokemonIdentity["Note7"] == ""),
    "IfNoNote8:": (pokemonIdentity["Note8"] == ""),
    "IfNoNote9:": (pokemonIdentity["Note9"] == ""),
    "IfAnySketch:": ( pokemonIdentity["Added Moves"] != ""),
    "IfAnyLv0Moves:": (pokemonIdentity["MovesLv0"] != ""),
    "IfAnyLv1Moves:": (pokemonIdentity["MovesLv1"] != ""),
    "IfAnyLv2Moves:": (pokemonIdentity["MovesLv2"] != ""),
    "IfAnyLv3Moves:": (pokemonIdentity["MovesLv3"] != ""),
    "IfAnyLv4Moves:": (pokemonIdentity["MovesLv4"] != ""),
    "IfUnown:": ("Unown" == speciesData["Name"]),
  };
}
/**
 * Using the true or false properties of the Pokemon, cuts off the ends of profile lines that
 * pass or fail the conditional placeholders within them.
 */
function splitByCriteria(profileLineString, profileCriteria) {
  for (const criteria in profileCriteria) {
    if (profileCriteria[criteria]) {
      profileLineString = profileLineString.split(criteria).join("");
    } else {
      profileLineString = profileLineString.split(criteria)[0];
    }
  }
  return profileLineString;
}
/**
 * Receives a row from the imported Pokemon Data table as input, and outputs an object with
 * the species' Data arranged and labeled for easier reference.
 */
function convertDataRowToObject(pokemonSpeciesData) {
  let pokemonDataObject = {
    "Name": pokemonSpeciesData[0][0], "Types": pokemonSpeciesData[0][1], "Abilities": pokemonSpeciesData[0][2], "Hidden": pokemonSpeciesData[0][3],
    "HP":  pokemonSpeciesData[0][4], "Atk": pokemonSpeciesData[0][5], "Def": pokemonSpeciesData[0][6],
    "SpA": pokemonSpeciesData[0][7], "SpD": pokemonSpeciesData[0][8], "Spe": pokemonSpeciesData[0][9],
    "Size": pokemonSpeciesData[0][10], "Weight": pokemonSpeciesData[0][11], "SigMoves": pokemonSpeciesData[0][12],
    "Traits": pokemonSpeciesData[0][13], "Sprite Name":pokemonSpeciesData[0][14],
    // These are filled in after generation, for the Pokemon's base Forme only.
    "Disciplines":[null, null, null], "Formes":[], "Sig Z-Moves":"", "Sig G-Max Moves":""
  }
  // Break the types cell into individual fields.
  const speciesTypes = pokemonDataObject["Types"].split("/"); // Store an array of 1 or 2 types.
  pokemonDataObject["Type1"] = speciesTypes[0];
  pokemonDataObject["Type2"] = speciesTypes.length > 1 ? speciesTypes[1] : null; // There may not be a Type2.
  // Break the Abilities and Hidden Abilities fields into arrays.
  pokemonDataObject["AbilityArray"] = pokemonDataObject["Abilities"].split(", ");
  pokemonDataObject["HiddenAbilityArray"] = pokemonDataObject["Hidden"].split(", ");
  return pokemonDataObject;
}
/**
 * Using the list of bool
 */
function replaceAllPlaceholders(profileString, pokemonIdentity, speciesData) {
  // Some quick pre-prep for speed reasons.
  const comboList = pokemonIdentity["Combo Array"];
  const comboSlotCount = pokemonIdentity["Combo Slots"];
 
  // We make a huge-ass object consisting of strings to be replaced (as keys), and their corresponding replacements (as values).
  // We do it this way because each individual Pokemon that has their profile generated needs their own list of
  // replacements, for each string to be replaced.
  const replacementList = {
    "MonNickname": (pokemonIdentity["Nickname"] == "") ? pokemonIdentity["Species"] : pokemonIdentity["Nickname"],
    "MonSpecies": speciesData["Name"], // We use speciesData instead of pokemonIdentity, because the former will
                                          //  accurately reflect the species name of each Forme in the forme block.
    "MonSpriteName": speciesData["Name"].toLowerCase().replace(/[:'.%]/,""),
    "MonShowdownSpriteName": speciesData["Name"].toLowerCase().replace(/[:'.%]/,""),
    "MonLevel": (pokemonIdentity["Level"] == "") ? 0 : pokemonIdentity["Level"],
    "MonEXP": (pokemonIdentity["EXP"] == "") ? 0 : pokemonIdentity["EXP"],
    "MonNextEXP": ( pokemonIdentity["EXP"] < 1 ? 1 : // Three cheers for hard-coded EXP Thresholds.
                    pokemonIdentity["EXP"] < 10 ? 10 :
                    pokemonIdentity["EXP"] < 30 ? 30 :
                    pokemonIdentity["EXP"] < 70 ? 70 : "--"),
    "MonType1": speciesData["Type1"],
    "MonType2": speciesData["Type2"],
    // Technique Details
    "MonHiddenType": (pokemonIdentity["Hidden Power Type"] == "") ? "Select a Hidden Power type" : pokemonIdentity["Hidden Power Type"],
    "MonTeraType": (pokemonIdentity["Tera Type"] == "") ? "Select a Tera Type" : pokemonIdentity["Tera Type"],
    "MonSigZMove": (speciesData["Sig Z-Moves"]) ? speciesData["Sig Z-Moves"] : "No Signature Z-Move",
    "MonGMaxMove": (speciesData["Sig G-Max Moves"]) ? speciesData["Sig G-Max Moves"] : "No Gigantimax Move",
    // Ability Details
    "MonAbility1": (speciesData["AbilityArray"][0] == "") ? "None" : speciesData["AbilityArray"][0],
    "MonAbility2": (speciesData["AbilityArray"].length < 2) ? "None" : speciesData["AbilityArray"][1],
    "MonAbility3": (speciesData["AbilityArray"].length < 3) ? "None" : speciesData["AbilityArray"][2],
    "MonHiddenAbility": (speciesData["HiddenAbilityArray"][0] == "") ? "None" : speciesData["HiddenAbilityArray"][0],
    "MonHiddenAbility2": (speciesData["HiddenAbilityArray"].length < 2) ? "None" : speciesData["HiddenAbilityArray"][1],
    "MonAutoStandardAbils": speciesData["AbilityArray"].join(", "),
    "MonAutoHiddenAbils": "".concat(
      (pokemonIdentity["Level"] > 1) ? "" : "[S]",
      speciesData["HiddenAbilityArray"].join(", "),
      (pokemonIdentity["Level"] > 1) ? "" : "[/S] (Locked)",
    ),
    "MonTraits": speciesData["Traits"],
    // Discipline 1 Details
    "MonDisc1Name":     ( null == pokemonIdentity["Disciplines"][0]) ? "No Discipline" : pokemonIdentity["Disciplines"][0]["Name"],
    "MonDisc1Level":    ( null == pokemonIdentity["Disciplines"][0]) ? "No Discipline" : pokemonIdentity["Disciplines"][0]["Level"],
    "MonDisc1Summary":  ( null == pokemonIdentity["Disciplines"][0]) ? "No Discipline" : pokemonIdentity["Disciplines"][0]["Summary"],
    "MonDisc1Flavor":   ( null == pokemonIdentity["Disciplines"][0]) ? "No Discipline" : pokemonIdentity["Disciplines"][0]["Flavor"],
    "MonDisc1Effects":  ( null == pokemonIdentity["Disciplines"][0]) ? "No Discipline" : pokemonIdentity["Disciplines"][0]["Effects"],
    "MonDisc1Tags":     ( null == pokemonIdentity["Disciplines"][0]) ? "No Discipline" : pokemonIdentity["Disciplines"][0]["Tags"],
    // Discipline 2 Details
    "MonDisc2Name":     ( null == pokemonIdentity["Disciplines"][1]) ? "No Discipline" : pokemonIdentity["Disciplines"][1]["Name"],
    "MonDisc2Level":    ( null == pokemonIdentity["Disciplines"][1]) ? "No Discipline" : pokemonIdentity["Disciplines"][1]["Level"],
    "MonDisc2Summary":  ( null == pokemonIdentity["Disciplines"][1]) ? "No Discipline" : pokemonIdentity["Disciplines"][1]["Summary"],
    "MonDisc2Flavor":   ( null == pokemonIdentity["Disciplines"][1]) ? "No Discipline" : pokemonIdentity["Disciplines"][1]["Flavor"],
    "MonDisc2Effects":  ( null == pokemonIdentity["Disciplines"][1]) ? "No Discipline" : pokemonIdentity["Disciplines"][1]["Effects"],
    "MonDisc2Tags":     ( null == pokemonIdentity["Disciplines"][1]) ? "No Discipline" : pokemonIdentity["Disciplines"][1]["Tags"],
    // Discipline 3 Details
    "MonDisc3Name":     ( null == pokemonIdentity["Disciplines"][2]) ? "No Discipline" : pokemonIdentity["Disciplines"][2]["Name"],
    "MonDisc3Level":    ( null == pokemonIdentity["Disciplines"][2]) ? "No Discipline" : pokemonIdentity["Disciplines"][2]["Level"],
    "MonDisc3Summary":  ( null == pokemonIdentity["Disciplines"][2]) ? "No Discipline" : pokemonIdentity["Disciplines"][2]["Summary"],
    "MonDisc3Flavor":   ( null == pokemonIdentity["Disciplines"][2]) ? "No Discipline" : pokemonIdentity["Disciplines"][2]["Flavor"],
    "MonDisc3Effects":  ( null == pokemonIdentity["Disciplines"][2]) ? "No Discipline" : pokemonIdentity["Disciplines"][2]["Effects"],
    "MonDisc3Tags":     ( null == pokemonIdentity["Disciplines"][2]) ? "No Discipline" : pokemonIdentity["Disciplines"][2]["Tags"],

    // Stat Symbols
    "MonHP": speciesData["HP"],
    "MonATK": speciesData["Atk"], "MonAttack": speciesData["Atk"],
    "MonDEF": speciesData["Def"], "MonDefense": speciesData["Def"],
    "MonSPA": speciesData["SpA"], "MonSpAtk": speciesData["SpA"], "MonSpecialAttack": speciesData["SpA"],
    "MonSPD": speciesData["SpD"], "MonSpDef": speciesData["SpD"], "MonSpecialDefense": speciesData["SpD"],
    "MonSPE": speciesData["Spe"], "MonSpeed": speciesData["Spe"],
    "MonSizeClass": speciesData["Size"],
    "MonWeightClass": speciesData["Weight"],
   
    // Combo Symbols.
    "MonCombosKnown": comboList.length,
    "MonComboSlots": comboSlotCount,
    // Yes, I would also prefer to do this with a for-loop. This is faster.
    "Combo1": (comboList[0] == "")    ? "(empty)" :
              (comboSlotCount < 1)    ? ""        :
              (comboList.length < 1)  ? "(empty)" : comboList[0],
    "Combo2": (comboSlotCount < 2)    ? ""        :
              (comboList.length < 2)  ? "(empty)" : comboList[1],
    "Combo3": (comboSlotCount < 3)    ? ""        :
              (comboList.length < 3)  ? "(empty)" : comboList[2],
    "Combo4": (comboSlotCount < 4)    ? ""        :
              (comboList.length < 4)  ? "(empty)" : comboList[3],
    "Combo5": (comboSlotCount < 5)    ? ""        :
              (comboList.length < 5)  ? "(empty)" : comboList[4],
    "Combo6": (comboSlotCount < 6)    ? ""        :
              (comboList.length < 6)  ? "(empty)" : comboList[5],
    // Movepool
    "MonAddedMoves": pokemonIdentity["Added Move Array"].join("\r"),
    "MonFullMovepool": "".concat(
      "[I][U]Level 0[/U][/I]\r", pokemonIdentity["MovesLv0"],
      (pokemonIdentity["MovesLv1"] != "") ? "\r\r[I][U]Level 1[/U][/I]\r" + pokemonIdentity["MovesLv1"] : "",
      (pokemonIdentity["MovesLv2"] != "") ? "\r\r[I][U]Level 2[/U][/I]\r" + pokemonIdentity["MovesLv2"] : "",
      (pokemonIdentity["MovesLv3"] != "") ? "\r\r[I][U]Level 3[/U][/I]\r" + pokemonIdentity["MovesLv3"] : "",
      (pokemonIdentity["MovesLv4"] != "") ? "\r\r[I][U]Level 4[/U][/I]\r" + pokemonIdentity["MovesLv4"] : "",
    ),
    "MonMovepoolLv0": pokemonIdentity["MovesLv0"],
    "MonMovepoolLv1": pokemonIdentity["MovesLv1"],
    "MonMovepoolLv2": pokemonIdentity["MovesLv2"],
    "MonMovepoolLv3": pokemonIdentity["MovesLv3"],
    "MonMovepoolLv4": pokemonIdentity["MovesLv4"],
    "Hidden Power": "Hidden Power (" + ((pokemonIdentity["Hidden Power Type"] == "") ? "Select a Hidden Power type" :
                                                                                       pokemonIdentity["Hidden Power Type"]) + ")",
    // Note Fields. Almost forgot these.
    "MonNote1": pokemonIdentity["Note1"],
    "MonNote2": pokemonIdentity["Note2"],
    "MonNote3": pokemonIdentity["Note3"],
    "MonNote4": pokemonIdentity["Note4"],
    "MonNote5": pokemonIdentity["Note5"],
    "MonNote6": pokemonIdentity["Note6"],
    "MonNote7": pokemonIdentity["Note7"],
    "MonNote8": pokemonIdentity["Note8"],
    "MonNote9": pokemonIdentity["Note9"],
    // Formatting
    "BlankLine": " ",
    "HorzLine": "[hr][/hr]",
    "LineBreak": "\n"//,
    //"\\n": "\n",
  }
  // Finally, we use our huge-ass object to replace each placeholder in the profile string, one at a time.
  for (const property in replacementList) {
    profileString = profileString.replaceAll(property, replacementList[property]);
  }
 
  // Return the fully replacementmaxxed profileString.
  return profileString;
}

Formatter.gs
JavaScript:
// A list of commands supported by the Profile Formatter.
const formatterHelpDict = {
  "PLACEHOLDERS": "(The formatter will replace these exact Placeholder strings with Pokemon data.)",
  "MonNickname": "The given nickname of the Pokemon, if any.",
  "MonSpecies": "The species name of the Pokemon.",
  "MonSpriteName": "Lowercase species name with non-alphanumeric characters removed.",
  "MonShowdownSpriteName": "As above, but formatted for Pokemon Showdown URLs specifically.",
  "MonLevel": "The Pokemon's current Level.",
  "MonExp": "The Pokemon's current Experience.",
  "MonNextEXP": "The next total amount of Experience needed to Level Up.",
  //"MonNature": "The current Nature of the Pokemon",
  //"MonShortNatureMods": "The stat modifiers of the Pokemon's Nature.",
  //"MonLongNatureMods": "As above, with stat names fully spelled out.",
  "MonType1": "The Pokemon's first type.",
  "MonType2": "The Pokemon's second type if any, or an empty string otherwise.",
  "MonAbility1": "The Pokemon's first ability.",
  "MonAbility2": "The Pokemon's second ability if any, or an empty string otherwise.",
  "MonAbility3": "The Pokemon's third ability if any, or an empty string otherwise.",
  "MonHiddenAbility": "The Pokemon's Hidden Ability if any. Empty string otherwise.",
  "MonHiddenAbility2": "The Pokemon's second Hidden Ability, for the few species that have it.",
  "MonAutoStandardAbils": "The Pokemon's non-hidden abilities, seperated with commas and spaces.",
  "MonAutoHiddenAbils": "The Pokemon's non-hidden abilities, seperated with commas and spaces.",
  "                ": "(If the Pokemon is Level 1 or lower, locked formatting will be added automatically.)",
  "MonTraits": "The Pokemon's Traits if any, or an empty string otherwise.",
  "MonDisc1Name": "The name of the Pokemon's first known Discipline, if any.",
  "MonDisc1Level": "The Level of the Pokemon's first known Discipline.",
  "MonDisc1Summary": "A brief summary of the Pokemon's first known Discipline.",
  "MonDisc1Flavor": "A quote describing the training philosphy of the first known Discipline.",
  "MonDisc1Effects": "The full effect text of the Pokemon's first known Discipline.",
  "MonDisc1Tags": "The Tags that the Pokemon's first known Discipline belongs to.",
  "             ": "...And so on, for Disc2 and Disc3.",
  "MonHP": "The Pokemon's maximum HP.", 
  "MonATK": "The Pokemon's Attack. (ATK)",
  "           ": "...And so on, for DEF, SPA, SPD, and SPE.",
  "MonSizeClass": "The Pokemon's Size Class.",
  "MonWeightClass": "The Pokemon's Weight Class.",
  //"MonUnmoddedATK": "The Pokemon's species Attack rank, before modifiers.",
  //"MonModdedATK": "The Pokemon's Attack rank after Nature.",
  //"MonATKMod": "The Attack increase or decrease of the Pokemon's Nature.",
  //"   ": "(Hidden if the Nature doesn't modify Attack.)",
  //"MonATKModSymbol": "Displays (+), (-), or nothing based on MonATKMod",
  //"     ": "(The above placeholders can use DEF, SPA, SPD, SPE, ACC, or EVA as well.)",
  "MonComboSlots": "The number of Combo Slots the Pokemon has earned.",
  "MonCombosKnown": "The number of Combo Slots the Pokemon has filled.",
  "Combo1": "The Pokemon's first known Combination.",
  "Combo2, Combo3": "...And so on, up to Combo6.",
  "MonAddedMoves": "Sketched Moves and/or Letter Moves, one set per line.",
  "MonFullMovepool": "The Pokemon's full movepool, with Level separators, one move per line.",
  "MonMovepoolLv0": "The Pokemon's Lv0 moves, with Level separators. Supports up to Lv4.",
  "MonNote1, MonNote2": "The Pokemon's specified note, up to MonNote9.",
  "BlankLine": "Inserts an empty line between other elements.",
  "HorzLine": "Identical to [hr][/hr].",
  "spacer1": null,
  "METADATA": "(Tells the formatter to treat certain lines differently.)",
  "        ": "NOTE: These lines can't have anything else on their line.",
  //"StartTechniques": "Begins a Tech block. Hidden if no tech is unlocked.",
  //"EndTechniques": "Ends a Tech block.",
  "StartFormes": "Begins a Forme block. Hidden if the Pokemon has no other Formes.",
  "      ": "(The Forme block is repeated once per Forme, and is hidden if the Pokemon has no Formes.",
  "       ": "\"MonSpecies\", etc. used inside the block will refer to the Forme instead.)",
  "EndFormes": "Ends a Forme block",
  //"RunFunct()": "Attempts to run the passed sheets function.",
  "spacer2": null,
  "SKIP BY CRITERIA": "(Tells the formatter to start skipping text.)",
  "##": "Start skipping regardless of content. Useful for commenting Formats to be shared.",
  "IfNickname:, IfNoNickname:": "Start skipping based on whether or not the Pokemon is nicknamed.",
  "IfLevel1:, IfLevel2:": "Start skipping depending on the Pokemon being a certain Level or higher.",
  "IfNotLevel1:, IfNotLevel2:": "Start skipping *unless* the Pokemon is a certain Level or higher.",
  "IfTrait:": "Start skipping if Traits are empty.",
  "IfAnyComboSlots:": "Start skipping unless the Pokemon has 1 or more Combo Slots.",
  "IfAnyCombosKnown:": "Start skipping unless the Pokemon knows 1 or more Combos.",
  "If1Combo:": "Start skipping unless at least 1 combo is known.",
  "If2Combos:": "...As above, up to If6Combos.",
  "IfAnyDiscs:": "Start skipping if the Pokemon has no Disciplines selected.",
  "IfAnyDiscSlots:": "Start skipping if the Pokemon has no Discipline slots unlocked.",
  "IfDisc1:": "Start skipping if the Pokemon's first Discipline isn't selected.",
  "IfDisc2:": "Start skipping if the Pokemon's second Discipline isn't selected.",
  "IfDisc3:": "Start skipping if the Pokemon's third Discipline isn't selected.",
  "IfNoDisc1:": "Start skipping if the Pokemon's first Discipline has been selected.",
  "IfNoDisc2:": "Start skipping if the Pokemon's second Discipline has been selected.",
  "IfNoDisc3:": "Start skipping if the Pokemon's third Discipline has been selected.",
  "IfDualType:": "Start skipping unless the Pokemon has a second type.",
  "IfSecondAbil:": "Start skipping unless the Pokemon has a second standard ability.",
  "IfHiddenAbil:": "Start skipping unless the Pokemon has any Hidden Abilities.",
  "IfHiddenAbil2:": "Start skipping unless the Pokemon has a second Hidden Ability.",
  //"IfType1Bug:": "Start skipping unless MonType1 is Bug.",
  //"IfType1Dark:": "...As above, for each type.",
  //"IfType2Bug:": "Start skipping unless MonType2 is Bug.",
  //"IfType2Dark:": "...As above, for each type.",
  "IfAnyFormes:": "Start skipping, unless the Pokemon has one or more in-battle Formes.",
  "IfNoFormes:": "Start skipping, unless the Pokemon has no in-battle Formes.",
  "If2PlusFormes:": "Start skipping, unless the Pokemon has two or more in-battle Formes.",
  "If1Forme:": "Start skipping, unless the Pokemon has no in-battle Formes.",
  "If2Formes:": "...And so on, up to If6Formes:.",
  "IfAnyTech:": "Start skipping unless any Advanced Technique has been unlocked.",
  "IfMega:": "Start skipping unless Mega Evolution is unlocked.",
  "IfZMove:": "Start skipping unless Z-Moves are unlocked.",
  "IfDyna:": "Start skipping unless Dynamax is unlocked.",
  "IfTera:": "Start skipping unless Terastal is unlocked.",
  "IfTriple:": "Start skipping unless the Triple Combo is unlocked.",
  "IfSigZ:": "Start skipping unless the species has a Signature Z-Move.",
  "IfGmaxMove:": "Start skipping unless the species has a Gigantimax Move.",
  "IfATKMod:": "Start skipping unless the Pokemon's Nature modifies their ATK, etc.",
  "IfNote1, IfNote2": "If the specified note is not empty, from Note 1 to Note 9",
  "IfAnySketch:": "Start skipping unless the Pokemon has Additional Moves.",
  "IfAnyLv0Moves:": "Start skipping unless the Pokemon has Level 0 Moves, up to Lv4.",
  "IfUnown:": "Start skipping unless the MonSpecies is an Unown Forme.",
  "LineJoin": "Start the line with this to add it to the end of the preceeding line.",
  " ": "(This is useful for combining multiple criteria into a single line.)",
  "LineBreak": "Splits the line into two consecutive lines.",
  "++": "Identical to LineJoin, just above.",
  "||": "Identical to LineBreak, just above."
  //":::": "Stop all skipping. Useful for combining multiple lines conditionally.",
  //" ": "(Skipping also stops at the end of each line, automatically.)",
  //"Not:": "Negates the immediate next criteria. (e.g. \"Not:IfMega:\")",
}

/**
 * Displays the help text for the Formatter, as defined in the Toolsheet's script.
 * Help text is defined via code so that when the code is updated by copying from
 * the Toolsheet Thread, the help text is also updated in lockstep.
 * 
 * @customfunction
 */
function DISPLAYFORMATTERGUIDE () {
  var formatterArray = [];
  for (const [key, value] of Object.entries(formatterHelpDict)) {
    if (value) {
      formatterArray.push([key, value]);
    } else {
      formatterArray.push([,]);
    }
  }
  return formatterArray;
}

Matchup Viewer.gs
JavaScript:
// We hardcode a type chart because it's very unlikely to change, and I don't want
// to have it take up valuable IMPORTRANGE() bandwidth.
const TYPE_CHART = {
  'Bug':      { 'chart':[0,0,0,0,0,-1,1,1,0,-1,-1,0,0,0,0,1,0,0],         'index':1 },
  'Dark':     { 'chart':[1,-1,0,0,1,1,0,0,-1,0,0,0,0,0,-99,0,0,0],        'index':2 },
  'Dragon':   { 'chart':[0,0,1,-1,1,0,-1,0,0,-1,0,1,0,0,0,0,0,-1],        'index':3 },
  'Electric': { 'chart':[0,0,0,-1,0,0,0,-1,0,0,1,0,0,0,0,0,-1,0],         'index':4 },
  'Fairy':    { 'chart':[-1,-1,-99,0,0,-1,0,0,0,0,0,0,0,1,0,0,1,0],       'index':5 },
  'Fighting': { 'chart':[-1,-1,0,0,1,0,0,1,0,0,0,0,0,0,1,-1,0,0],         'index':6 },
  'Fire':     { 'chart':[-1,0,0,0,-1,0,-1,0,0,-1,1,-1,0,0,0,1,-1,1],      'index':7 },
  'Flying':   { 'chart':[-1,0,0,1,0,-1,0,0,0,-1,-99,1,0,0,0,1,0,0],       'index':8 },
  'Ghost':    { 'chart':[-1,1,0,0,0,-99,0,0,1,0,0,0,-99,-1,0,0,0,0],      'index':9 },
  'Grass':    { 'chart':[1,0,0,-1,0,0,1,1,0,-1,-1,1,0,1,0,0,0,-1],        'index':10 },
  'Ground':   { 'chart':[0,0,0,-99,0,0,0,0,0,1,0,1,0,-1,0,-1,0,1],        'index':11 },
  'Ice':      { 'chart':[0,0,0,0,0,1,1,0,0,0,0,-1,0,0,0,1,1,0],           'index':12 },
  'Normal':   { 'chart':[0,0,0,0,0,1,0,0,-99,0,0,0,0,0,0,0,0,0],          'index':13 },
  'Poison':   { 'chart':[-1,0,0,0,-1,-1,0,0,0,-1,1,0,0,-1,1,0,0,0],       'index':14 },
  'Psychic':  { 'chart':[1,1,0,0,0,-1,0,0,1,0,0,0,0,0,-1,0,0,0],          'index':15 },
  'Rock':     { 'chart':[0,0,0,0,0,1,-1,-1,0,1,1,0,-1,-1,0,0,1,1],        'index':16 },
  'Steel':    { 'chart':[-1,0,-1,0,-1,1,1,-1,0,-1,1,-1,-1,-99,-1,-1,-1,0],'index':17 },
  'Water':    { 'chart':[0,0,0,1,0,0,-1,0,0,1,0,-1,0,0,0,0,-1,-1],        'index':18 }
}
// Hardcoded chart of Effectiveness Multipliers, since they are unlikely to change.
// Negative indexes count from the end of the array.
const EFFECTIVENESS_CHART = [1, 1.5, 2, 2.5, 3, 0.01, 0.25, 0.5, 0.75];


/**
 * Clears the stack of Pokemon awaiting analysis in the Matchup page.
 */
function clearMatchupPokemonConfig() {
  const ui = SpreadsheetApp.getUi();
  const response = ui.alert(
    "Clear Matchup Pokemon Configuration", 
    "Delete all prepared Pokemon awaiting analysis in the Matchup page, as well as all "+
    "custom Movepools stored in the Matchup page; leaving a blank slate?",
    Browser.Buttons.YES_NO)
  
  if (response === ui.Button.YES) {
    ui.alert('You clicked "Yes".')
  }
}

/**
 * Receives two Species names, a Level, a movepool, and a set of configurations.
 * Outputs a sorted array of moves, each with a type, a damage estimate, and an
 * additional parameter as specified by the configuration.
 * 
 * @param {Array} attackerInput The row of imported Pokemon Data for the ally Pokemon.
 * @param {Array} defenderInput The row of imported Pokemon Data for the enemy Pokemon.
 * @param {Array} movepool The movepool of the ally Pokemon.
 * @param {Array<Array>} optionsRange An array of the matchup tool's configuration options for the ally Pokemon's side.
 * @param {Array<Array>} moveBattleDataRange The range of imported Move data.
 * @param {Array<Array>} validMoveWarnlists An array of Warnlists currently set to apply to the Matchup Tool.
 * 
 * @customfunction
 */
function MAKEMATCHUPMOVEPOOL(attackerInput, defenderInput, movepool, optionsRange, moveBattleDataRange, validMoveWarnlists) {
  // First, parse the passed optionsRange into an Object.
  const options = {
    "Sort": optionsRange[0][0],
    "Flex Column": optionsRange[1][0],
    "Filter": optionsRange[2][0],
    "Power": optionsRange[3][0]
  }
  // Dummy check.
  if ( null == attackerInput || null == defenderInput) {
    return "Invalid Species.";
  }
  // Arrange species data for the two pokemon into objects
  const attackerSpecies = {
    "Name": attackerInput[0][0], "Types": attackerInput[0][1], "Abilities": attackerInput[0][2], "Hidden": attackerInput[0][3],
    "HP":  attackerInput[0][4], "Atk": attackerInput[0][5], "Def": attackerInput[0][6],
    "SpA": attackerInput[0][7], "SpD": attackerInput[0][8], "Spe": attackerInput[0][9],
    "Size": attackerInput[0][10], "Weight": attackerInput[0][11], "Sig": attackerInput[0][12]
  }
  const defenderSpecies = {
    "Name": defenderInput[0][0], "Types": defenderInput[0][1], "Abilities": defenderInput[0][2], "Hidden": defenderInput[0][3],
    "HP":  defenderInput[0][4], "Atk": defenderInput[0][5], "Def": defenderInput[0][6],
    "SpA": defenderInput[0][7], "SpD": defenderInput[0][8], "Spe": defenderInput[0][9],
    "Size": defenderInput[0][10], "Weight": defenderInput[0][11], "Sig": defenderInput[0][12]
  }
  // Find the stat differences between the attacker and defender, and make a defending effectiveness table for the enemy.
  const physDiff = attackerSpecies["Atk"] - defenderSpecies["Def"];
  const specDiff = attackerSpecies["SpA"] - defenderSpecies["SpD"];
  const accDiff = 0; // Used for the "Accurate" filter
  const defenderTypeChart = makeEffectivenessObject(defenderSpecies["Types"]);

  // Fetch and hold the table of battle data for moves.
  const movesData = moveBattleDataRange;
  const moveWarnlist = validMoveWarnlists[0];
  // Find the column index that should be used for the Flex Column.
  let flexColumnDataIndex = 0;
  let flexColumnSortFunction = function(a, b) { return b[2] - a[2] };
  switch ( options["Flex Column" ]) {
    case "Category":
      flexColumnDataIndex = 2;
      flexColumnSortFunction = function(a, b) { return a[2].localeCompare(b[2]) };
      break;
    case "Target Scope":
      flexColumnDataIndex = 3;
      flexColumnSortFunction = function(a, b) { return a[2].localeCompare(b[2]) };
      break;
    case "Accuracy":
      flexColumnDataIndex = 5;
      flexColumnSortFunction = function(a, b) { let aNum = parseInt(a[2]); aNum = isNaN(aNum) ? 0 : aNum; 
                                                let bNum = parseInt(b[2]); bNum = isNaN(bNum) ? 0 : bNum;
                                                return bNum - aNum };
      break;
    case "Energy Cost":
      flexColumnDataIndex = 6;
      break;
    case "Effect Chance":
      flexColumnDataIndex = 7;
      flexColumnSortFunction = function(a, b) { let aNum = parseInt(a[2]); aNum = isNaN(aNum) ? 0 : aNum; 
                                                let bNum = parseInt(b[2]); bNum = isNaN(bNum) ? 0 : bNum;
                                                return bNum - aNum };
      break;
    case "Priority":
      flexColumnDataIndex = 8;
      break;
    case "C.Limit":
      flexColumnDataIndex = 9;
      flexColumnSortFunction = function(a, b) { let aNum = a[2] == "Banned" ? 2 : a[2] == "Status" ? 1 : a[2] == "One" ? 0 : -1;
                                                let bNum = b[2] == "Banned" ? 2 : b[2] == "Status" ? 1 : b[2] == "One" ? 0 : -1;
                                                return bNum - aNum };
      break;
    case "Contact":
      flexColumnDataIndex = 10;
      flexColumnSortFunction = function(a, b) { return b[2].localeCompare(a[2]) }; // Reverse sort for flag columns so that "Yes" is before "No".
      break;
    case "Snatchable":
      flexColumnDataIndex = 11;
      flexColumnSortFunction = function(a, b) { return b[2].localeCompare(a[2]) }; // Reverse sort for flag columns so that "Yes" is before "No".
      break;
    case "Reflectable":
      flexColumnDataIndex = 12;
      flexColumnSortFunction = function(a, b) { return b[2].localeCompare(a[2]) }; // Reverse sort for flag columns so that "Yes" is before "No".
      break;
    default: 
      break;
  }
  // Filter the movelist to remove any entries that don't meet the passed filter criteria.
  let filterFunction = function(moveData) { return true; }
  switch (options["Filter"]) {
    case "Super-Effective":
      filterFunction = function(moveData) { return defenderTypeChart[moveData[1]] > 1 && moveData[2] != "Other"; }
      break;
    case "Attacks":
      filterFunction = function(moveData) { return moveData[2] != "Other"; }
      break;
    case "Non-Attacks":
      filterFunction = function(moveData) { return moveData[2] == "Other"; }
      break;
    case "Combo-Legal":
      filterFunction = function(moveData) { return moveData[9] != "Banned"; }
      break;
    case "Accurate":
      filterFunction = function(moveData) { return (moveData[5] == "--" || (parseInt(moveData[5]) + accDiff) >= 100) }
      break;
    case "Inaccurate":
      filterFunction = function(moveData) { return (moveData[5] != "--" && (parseInt(moveData[5]) + accDiff) < 100) }
      break;
    case "Warnlist":
      filterFunction = function(moveData) { return moveWarnlist.includes(moveData[0]) }
      break;
    case "Not Warnlist":
      filterFunction = function(moveData) { return !moveWarnlist.includes(moveData[0]) }
      break;
  }
  // Build the actual table to be output.
  let movepoolTable = [];
  const splitMovepool = movepool.map( (moveRow) => moveRow[0] );
  // For each extant Move...
  for (var i = 0; i < movesData.length; i++) {
    const moveRow = movesData[i];
    // ...Check if that Move is in our attacker's movepool.
    if ( splitMovepool.includes(moveRow[0]) ) {
      // Skip the Move if it fails the passed filter criteria.
      if ( !filterFunction(moveRow) )  { continue; }
      // Format the power to be displayed in the table.
      let powerDisplay = moveRow[4]; // Don't do parseInt() yet.
      if ( !isNaN(parseFloat(powerDisplay)) && options["Power"] != "Raw BAP") {
        powerDisplay = parseFloat(powerDisplay);
        powerDisplay += (moveRow[2] == "Physical") ? physDiff : specDiff;
        powerDisplay += (attackerSpecies["Types"].split('/').includes(moveRow[1])) ? 4 : 0; // STAB
        if (options["Power"] == "Estimated Damage") {
          powerDisplay *= defenderTypeChart[moveRow[1]];
        }
        powerDisplay = Math.max(1, powerDisplay);
      }
      
      movepoolTable.push([moveRow[0], moveRow[1], moveRow[flexColumnDataIndex], powerDisplay]);
    }
  }
  if (movepoolTable === undefined || movepoolTable.length == 0) { return "No moves found."; }
  // Sort the table based on the passed options.
  switch (options["Sort"]) {
    case "Type":
      return movepoolTable.sort((a, b) => a[1].localeCompare(b[1]));
    case "Flex Column":
      return movepoolTable.sort(flexColumnSortFunction);
    case "Power":
      return movepoolTable.sort(function(a, b) {let aNum = a[3] == "??" ? 999 : parseFloat(a[3]); aNum = isNaN(aNum) ? 0 : aNum; 
                                              let bNum = b[3] == "??" ? 999 : parseFloat(b[3]); bNum = isNaN(bNum) ? 0 : bNum;
                                              return bNum - aNum });
    default: // the array is already sorted by Name by default.
      return movepoolTable;
  }
}


/**
 * Receiving a Type, or two Types in one String seperated by "/", returns an Object with
 * an Effectiveness for each extant Type.
 */
function makeEffectivenessObject(inputTypes = "") {
  // Get the list of object keys in the type chart.
  const extantTypes = Object.keys(TYPE_CHART);
  // Ensure that any input types actually exist.
  let ourTypes = [];
  for (const type of inputTypes.split('/')) {
    if (-1 != extantTypes.indexOf(type)) { 
      ourTypes.push(type);
    }
  }
  // this object will hold all of our { "Bug":1.5, "Dark":1.5 ... } multipliers for export.
  var multiChart = {};
  // for each extant type... get the type's rowIndex
  for (var attackTypeName of extantTypes) { // { chart:[0,0,0], index:0}
    // get the index of the "attacking type"; our types are the "defending types"
    var attackingIndex = TYPE_CHART[attackTypeName].index - 1; // The DAT stores index as "rowIndex" starting at 1. So we adjust index by 1.
    var effectivenessLevel = 0;
    // Iterate over our types, tallying up the effectiveness of the attackType over our two type names.
    for (var defTypeName of ourTypes) { 
      var defType = TYPE_CHART[defTypeName];
      effectivenessLevel += defType.chart[attackingIndex];
    }
    // Set the multiChart's value for the current attacking type to the effectiveness multiplier that we've derived.
    if (effectivenessLevel < -50) { 
      // If immune, set multi to zero and move on...
      multiChart[attackTypeName] = 0;
    } else { 
      // ..otherwise, reference the conversion table, like in Rule 8.5
      effectivenessLevel = Math.max(Math.min(effectivenessLevel, 4), -4);
      multiChart[attackTypeName] = EFFECTIVENESS_CHART.at(effectivenessLevel);
    }
  }
  return multiChart;
}

Combo Builder.gs
JavaScript:
/**
 * @customfunction
 */
function COMBOLEGAL(moveDataRange, rawMoves, movepool, allCombos) {
  
  // Check for at least 2 Combo components.
  const movesToCheck = rawMoves[0].filter( (move) => move !== "" );
  if ( movesToCheck.length < 1 ) { return "Select Moves."}
  if ( movesToCheck.length < 2 ) { return "Select 2+ Moves."}
  // Prepare variables for reference.
  const moveNamesArray = moveDataRange.map( (row) => row[0].toLowerCase() );
  const movesKnown = movepool.flat().map( (move) => move.toLowerCase() );
  const movesInCombos = allCombos.flat().map( (move) => move.toLowerCase() );
  let movesWithStatusLimit = [];
  let movesWithOneLimit = [];
  let movesNotKnown = [];
  for ( var i = 0; i < movesToCheck.length; i++) {
    const moveName = movesToCheck[i];
    const moveIndex = moveNamesArray.indexOf( moveName.toLowerCase() );
    if ( moveIndex < 0 ) { return moveName + " is not a move."; }
    // Ensure the move isn't used in any other Combos.
    if ( movesInCombos.filter( (move) => move.toLowerCase() == moveName.toLowerCase() ).length > 1 ) { 
        return moveName + " is used in more than one Combo." 
    }
    // Fetch and store the move's data.
    const moveData = moveDataRange[moveIndex];
    const moveCategory = moveData[2];
    const moveCLimit = moveData[9];
    // Depending on the move's data, reject the combo.
    if ( "Banned" == moveCLimit) { return moveName + " is Banned from Combos." }
    if ( "Physical" == moveCategory && movesWithStatusLimit.length > 0 || 
          "Special" == moveCategory && movesWithStatusLimit.length > 0 ) { 
      return movesWithStatusLimit[0] + " is illegal with any attacks.";
    }
    if ( "One" == moveCLimit && movesWithOneLimit.length > 0 ) {
      return movesWithOneLimit[0] + " and " + moveName + " are illegal with each other.";
    }
    // Store moves as potentially combo-invalidating.
    if ( "One" == moveCLimit ) { movesWithOneLimit.push(moveName); }
    if ( "Status" == moveCLimit ) { movesWithStatusLimit.push(moveName); }
    // Store moves if they aren't known by the Pokemon.
    if ( !movesKnown.includes(moveName.toLowerCase()) ) { movesNotKnown.push(moveName) }
  }
  if ( movesNotKnown.length > 0 ) { return "Legal, if " + movesNotKnown.join(" or ") + " were known."; }
  return "Legal.";
}
 
Back
Top