BvS Peace Treaty Helper

Disables villages with which you have a peace treaty in spy/bingo/attack menus.

// ==UserScript==
// @name        BvS Peace Treaty Helper
// @namespace   ns://yesterday.BvS.local/
// @description Disables villages with which you have a peace treaty in spy/bingo/attack menus.
// @include     http://*animecubed.com/billy/bvs/village.html
// @include     http://*animecubed.com/billy/bvs/bingo.html
// @include     http://*animecubed.com/billy/bvs/villageattack.html
// @include     http://*animecubed.com/billy/bvs/villagespy.html
// @include     http://*animecubed.com/billy/bvs/villagespyreport.html
// @version     1.2.1
// @history     1.2.1 [2014-09-14] Cleaner, more generic compatibility hack.
// @history     1.2.0 [2014-09-13] Scriptish 0.1.12 compatibility hack.
// @history     1.1.0 [2014-09-12] Changed list formatting to use "Pax List".
// @history     1.0.5 [2014-09-12] Enabled multiball.
// @history     1.0.4 [2014-09-12] Changed list formatting to double-parenthesis.
// @history     1.0.3 [2014-09-12] Items are now removed from the list entirely, rather than merely being disabled.
// @history     1.0.2 [2014-09-11] Added spy report modification.
// @history     1.0.1 [2014-09-09] Changed list formatting from bracketed (impossible) to parenthesized.
// @history     1.0.0 [2014-09-08] Initial version.
// @license     WTFPL (http://wtfpl.org/)
// @grant       GM_setValue
// @grant       GM_getValue
// ==/UserScript==

/////////////////////////
// Options
/////////////////////////

// If the announcement parser fails badly for one or more of your alts,
// you can override it on a per-alt basis.
// For example, if you have three alts named "Main", "Alt 1", and "Alt 2":
//
// const overrideList = {
//   "Main":  ["VillageOne", "Village 2", "Village3"], // override: peace with just these three villages
//   "Alt 1": null,                                    // don't override at all (this can be omitted)
//   "Alt 2": [],                                      // override: peace with no villages
// };
//
const overrideList = null;

// Set this to "true" to see the results of the announcement parser in the console.
const logParseToConsole = false;

/////////////////////////
// Main
/////////////////////////

(function(outerGMGet,outerGMSet) { try {
  
  // Multiball! Handle multiple alts.
	const playerName = document.evaluate(
		"//input[@name='player']", document, null,
		XPathResult.ANY_UNORDERED_NODE_TYPE, null
	).singleNodeValue.value;
  
  // Get values on a per-alt basis. Non-Greasemonkey script engines typically
	// can't handle raw array values, so we also manually JSONify our data.
  function GM_setValue(key, val) {
		return outerGMSet(playerName + ";" + key, JSON.stringify(val));
	}
  function GM_getValue(key, def) {
		key = playerName + ";" + key;
		const val = outerGMGet(key, def);
		if (val) try { 
			return JSON.parse(val); 
		} catch (e) {
			console.log({err: "GM_getValue JSON parse failure!", key: key, val: val});
		}
		return def;
	}
 
  function parseAnnouncement() 
  {
    // If the user has manually specified a list of villages, use that instead of
    // checking the village announcement.
    if (overrideList && overrideList[playerName])
      return overrideList[playerName];
    
    // The kage may specify one or more lists of peaceable villages as a parenthesized,
    // comma-separated list in the village announcement, prefixed with "Pax List:",
    // e.g.:
    //
    //                Pax List: (Village1, Village2, Vil Lage3, Etc)
    //
    // Spacing and formatting is mostly ignored, but capitalization (except for the
    // initial "[Pp]ax [Ll]ist:") is not. Do not use quotation marks.
    const announcement = document.getElementById("annul");
    const match = announcement.textContent.match(/[Pp]ax [Ll]ist\:\s*\((\s*[\w ]+\s*,)*[\w ]+\)/g);
    if (match) {
      return match.map(function(L){return L.match(/\((.*)\)/)[1].split(",");})
        .reduce(function(a,b){return a.concat(b)})
        .map(function(s){return s.trim();});
    }
    
    // Otherwise, we have to guess. In so doing we just pull ALL the potential village
    // names out of the announcement text.
    // 
    // Spaces are potentially part of a village name, which is more helpful than harmful:
    // it means we're not typically going to have to worry about (e.g.) "That Village"
    // being triggered by a sentence like "That won't help." Instead, the detected strings
    // will be "That won" and "t help".
    //
    // There are still things you can name a village to sneak your way onto this list:
    // "Invading" or "Sannin", for example, will often work. Of course, players can always
    // just turn off the script...
    const textblocksSnapshot = document.evaluate(
      '//*[@id="annul"]//text()', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    const textsArray = (function(){
      var a = [];
      for (var i = 0; i < textblocksSnapshot.snapshotLength; ++i) {
        const node = textblocksSnapshot.snapshotItem(i);
        a = a.concat(node.textContent.split(/[^\w ]/).map(function(e){return e.trim();}));
      }
      // filter out "and " if the kage uses the Oxford comma
      a = a.map(function(s){ if (s.match(/^and /)) return s.slice(4); return s; });

      // filter out "and " if the kage _doesn't_ use the Oxford comma, or in two-item lists
      // (this prevents villages with the five-character string " and " in their names from
      // being detected, but at time of writing no such villages exist)
      a = a.map(function(s){ return s.split(/ and /g); })
           .reduce(function(A,B){ B.forEach(function(v){A.push(v);}); return A;},[]);
      
      // filter out null strings and strings too long to be a village name
      a = a.filter(function(e){return e.length > 0 && e.length <= 10});
      
      // filter out duplicate strings (keep only one instance)
      a = Object.getOwnPropertyNames(a.reduce(function(o,k){o[k]++;return o;},({}))).sort();
      
      return a;
    })();
    
    // As a special case, we also check to see if there's a village set for invasion
    // and, if it's there, remove it from the list.
    const invadeMatch = document.body.textContent.match(
      /Planning to Invade: (.*) Village/);
    if (invadeMatch && invadeMatch[1]) {
      const invadeIndex = textsArray.indexOf(invadeMatch[1]);
      if (invadeIndex != -1) textsArray.splice(invadeIndex, 1);
    }
    
    return textsArray;
  }

  function disableInputsInForm(theForm, verbotenList)
  {
    const inputNodes = Array.prototype.filter.call(
      theForm.elements,
      function(n){return n.type != "hidden"}
    );
    
    const removedVillages = [];

    inputNodes.forEach(function(input) {
      if (input.type == "hidden") return;
      if (verbotenList.indexOf(input.value) == -1) return;
      
      // collect all the text used to label this input
      const labels = (function(n) {
        var a = [];
        while (true) {
          a.push(n);
          n = n.nextSibling;
          if (!n) break;
          if (n.tagName && ({input:1})[n.tagName.toLowerCase()]) break;
        };
        return a;
      })(input);

      labels.forEach(function(n){ n.parentNode.removeChild(n); });
      // replace real spaces in village name with nbsp
      removedVillages.push(input.value.replace(/ /g, ' '));
    });
    
    // add note concerning removed villages
    if (removedVillages.length > 0) {
      const container = theForm.parentNode;
      
      // outer div: border
      const div = document.createElement("div");

      const dStyle = div.style;
      const cStyle = getComputedStyle(container);
      
      // copy border style, width
      ["Left","Right","Bottom"].forEach(function(a){
        ["Color","Style","Width"].forEach(function(b){
          const k = "border" + a + b;
          dStyle[k] = cStyle[k];
        });
      });
      dStyle.width = cStyle.width;
      
      // inner div: text styling, text
      div.innerHTML = "<div style='color: #040; font: bold italic x-small sans-serif;\
margin: 0px 5px; text-align: right'>TEXT</span>";
      
      // add actual village list (note: NBSP in "Not shown")
      div.firstChild.firstChild.textContent = (
        "Not shown: " + removedVillages.sort().join(", ")
      );

      // insert after container
      container.parentNode.insertBefore(div, container.nextSibling);
    }
  }

  function disableFromSpyReport(verbotenList) {
    const nameTexts = (function(){
      const a = [];
      const ss = document.evaluate("//tr/td[2]/a/text()", document, null,
                                   XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
      for (var i = 0; i < ss.snapshotLength; ++i)
        a.push(ss.snapshotItem(i));
      return a;
    })();
    
    nameTexts.forEach(function(t){
      if (verbotenList.indexOf(t.textContent.trim()) == -1)
        return;
      const td = t.parentNode.parentNode.parentNode;
      td.style.textDecoration = "line-through";
      td.style.color = "#888"
      t.parentNode.style.color = "#888";
    });
  };
  
  // main function
  (function(){
    "use strict";
    const pageURL = document.location.href;
    
    if (pageURL.endsWith("/village.html")) {
      // Get names from announcement
      const candidateNames = parseAnnouncement();
      if (logParseToConsole) console.log(candidateNames);
      GM_setValue("peaceList", candidateNames);
      return;
    }
   
    const verbotenList = GM_getValue("peaceList", []);
    
    if (pageURL.endsWith("/bingo.html")) {
      disableInputsInForm(document.forms.lookinto, verbotenList);
      return;
    }

    if (pageURL.endsWith("/villageattack.html")) {
      disableInputsInForm(document.forms.lookinto, verbotenList);
      return;
    }

    if (pageURL.endsWith("/villagespy.html")) {
      disableInputsInForm(document.forms.lookinto, verbotenList);
      disableInputsInForm(document.forms.multili, verbotenList);
      return;
    }
    
    if (pageURL.endsWith("/villagespyreport.html")) {
      disableFromSpyReport(verbotenList);
      return;
    }
    
  })();
  
} catch (e) {
  console.log(e);
}
})(GM_getValue, GM_setValue);