class Svg {
  /**
   * Build a node from XML
   *
   * @param {String} code
   * @param {String|null} wrapper - wrapper element, by default no wrapper.
   * @param {Object|null} wrapperOpts - opts to add to the wrapper
   * @returns {Node}
   */
  static fromXML(code, wrapper = null, wrapperOpts = null) {
    let n = document.createElementNS("http://www.w3.org/2000/svg", wrapper || 'g');

    if (wrapperOpts) {
      for (let p in wrapperOpts) {
        if (wrapperOpts.hasOwnProperty(p)) {
          n.setAttribute(p, wrapperOpts[p]);
        }
      }
    }

    n.innerHTML = code.trim();
    if (wrapper === null) {
      return n.childNodes[0];
    } else {
      return n;
    }
  }

  /**
   * Build a node from XML, wrapped in `<g>`
   *
   * @param {String} code
   * @param {Object|null} wrapperOpts - opts to add to the wrapper
   * @returns {Node}
   */
  static fromXMLg(code, wrapperOpts = null) {
    return Svg.fromXML(code, 'g', wrapperOpts);
  }
}

/* --------- Shared Constants --------- */
const BOARD_SIZE = 121;

const METAL_SEQ = ['lead', 'tin', 'iron', 'copper', 'silver', 'gold'];

const SYMBOLS_METALS = [
  'mercury', 'lead', 'tin', 'iron', 'copper', 'silver', 'gold'
];

const SYMBOLS_ALL = [
  'salt', 'air', 'fire', 'water', 'earth', 'mercury', 'lead',
  'tin', 'iron', 'copper', 'silver', 'gold', 'mors', 'vitae'
];

/**
 * Convert grid coordinates to gameGrid array index
 *
 * @param {Number} x
 * @param {Number} y
 * @returns {Number}
 */
function xyToGridIndex(x, y) {
  return y * 11 + x
}

/**
 * Convert grid index to X, Y
 *
 * @param {Number} index
 * @returns {{x: Number, y: Number}}
 */
function gridIndexToXy(index) {
  return {
    x: index % 11,
    y: Math.floor(index / 11)
  };
}

/**
 * Get if a coordinate is outside the game board.
 *
 * @param x
 * @param y
 * @return {boolean|boolean}
 */
function isXyOutside(x, y) {
  return x < 0
    || x > 10
    || y < 0
    || y > 10
    || (y <= 5 && x > 5 + y)
    || (y > 5 && x < y - 5);
}

/**
 * Get if a coordinate is on the board border.
 *
 * @param x
 * @param y
 * @return {boolean|boolean}
 */
function isXyBorder(x, y) {
  return x === 0
    || x === 10
    || y === 0
    || y === 10
    || (y <= 5 && x === 5 + y)
    || (y > 5 && x === y - 5);
}

/**
 * Game board
 *
 * Orb grid coordinates:
 *   x - grid X coordinate (counted from the left edge of a triangle starting in the top left corner)
 *   y - grid Y coordinate (row)
 */
class Board {
  constructor() {
    this.$bg = document.getElementById('boardbg');
    this.$orbs = document.getElementById('orbs');
    this.$svg = document.getElementById('board');
    this.$root = document.getElementById('root');

    this.TILE_W = 91;
    this.TILE_H = 79;
    this.SCREEN_PAD = 20;

    // Orb grid
    this.grid = [];
    this.tiles = [];
    this.buttons = {};

    for (let i = 0; i < BOARD_SIZE; i++) {
      this.grid[i] = null;
      this.tiles[i] = null;
    }

    this.onOrbClick = (index, orb) => {
      // placeholder
    };
    this.onTileClick = (index) => {
      // placeholder
    };

    this.initOrb();
    this.initGlyphs();
    this.initTile();

    this.buildBackground();

    this.buildGui();

    this.initAutoScaling();
  }

  /**
   * Show all orbs for graphics debugging
   */
  testGraphics() {
    let o;

    this.placeOrb(0, 0, 'salt');
    this.placeOrb(1, 0, 'air');
    this.placeOrb(2, 0, 'fire');
    o = this.placeOrb(3, 0, 'water');
    o.node.classList.add('selected');
    this.placeOrb(4, 0, 'earth');
    this.placeOrb(5, 0, 'mercury');
    this.placeOrb(0, 1, 'lead');
    this.placeOrb(1, 1, 'tin');
    this.placeOrb(2, 1, 'iron');
    this.placeOrb(3, 1, 'copper');
    this.placeOrb(4, 1, 'silver');
    this.placeOrb(5, 1, 'gold');
    this.placeOrb(6, 1, 'vitae');
    this.placeOrb(0, 2, 'mors');

    o = this.placeOrb(1, 2, 'copper');
    o.node.classList.add('disabled');

    o = this.placeOrb(2, 2, 'copper');
    o.node.classList.add('disabled');

    this.highlight('copper');
  }

  /**
   * Resize to window and set up window resize event handler.
   */
  initAutoScaling() {
    this.rescaleTimeout = null;
    window.addEventListener('resize', () => {
      if (this.rescaleTimeout === null) {
        this.rescaleTimeout = setTimeout(() => this.rescaleCanvas(), 60)
      }
    });

    this.rescaleCanvas();
  }

  /**
   * Resize to current window size
   */
  rescaleCanvas() {
    let w = window.innerWidth;
    let h = window.innerHeight;
    const pad = this.SCREEN_PAD;
    let scaleX = w / (1066 + pad * 2);
    let scaleY = h / (926 + pad * 2);
    let scale = Math.min(scaleX, scaleY);
    this.$root.setAttribute('transform', `translate(${w / 2},${h / 2}) scale(${scale})`);

    this.rescaleTimeout = null;
  }

  /**
   * Highlight a symbol. All other highlights are removed.
   *
   * @param {String|null} symbol - symbol to highlight, null to hide highlights
   */
  highlight(symbol = null) {
    SYMBOLS_ALL.forEach((s) => {
      this.$svg.classList.toggle(`highlight-${symbol}`, symbol === s);
    });
  }

  /**
   * Convert grid coordinates to graphic coordinates
   *
   * @param {Number} x
   * @param {Number} y
   * @returns {{rx: number, ry: number}}
   */
  gridXyToCoord(x, y) {
    let rx = this.TILE_W * (-2.5 + x - y * 0.5);
    let ry = this.TILE_H * (-5 + y);
    return {rx, ry};
  }

  /**
   * Convert GUI coordinates to graphic coordinates
   *
   * @param {Number} x
   * @param {Number} y
   * @returns {{rx: number, ry: number}}
   */
  guiXyToCoord(x, y) {
    let rx = this.TILE_W * (-5.9 + x);
    let ry = this.TILE_H * (-5.5 + y);
    return {rx, ry};
  }

  /**
   * Remove an orb from the grid at the given coordinates.
   *
   * @param {Number} x - board X
   * @param {Number} y - board Y
   */
  removeOrb(x, y) {
    const index = xyToGridIndex(x, y);
    this.removeOrbByIndex(index)
  }

  /**
   * Count orbs in the game board
   *
   * @return {number}
   */
  countOrbs() {
    return this.grid.reduce((acu, x) => acu + (x !== null), 0);
  }

  /**
   * Remove orb by array index
   *
   * @param {Number} index
   * @param {Boolean} errorIfEmpty
   */
  removeOrbByIndex(index, errorIfEmpty = false) {
    // placeholder orb
    if (typeof this.grid[index] === 'string') {
      this.grid[index] = null;
      return;
    }

    if (this.grid[index]) {
      this.$orbs.removeChild(this.grid[index].node);
      this.grid[index] = null;
    } else {
      if (errorIfEmpty) {
        throw new Error(`Position ${index} is already empty.`);
      }
    }
  }

  /**
   * Place an orb by array index
   *
   * @param {Number} index
   * @param {String} symbol
   * @return {{node: Node, symbol: String}}
   */
  placeOrbByIndex(index, symbol) {
    const {x, y} = gridIndexToXy(index);
    return this.placeOrb(x, y, symbol);
  }

  /**
   * Place an orb on the grid
   *
   * @param {Number} x - board X
   * @param {Number} y - board Y
   * @param {String} symbol - alchemical symbol name
   * @returns {{node : Node, symbol: String}} - orb object
   */
  placeOrb(x, y, symbol) {
    const {rx, ry} = this.gridXyToCoord(x, y);
    const arrayIndex = xyToGridIndex(x, y);

    this.removeOrbByIndex(arrayIndex);

    let template;
    if (SYMBOLS_METALS.includes(symbol)) {
      template = this.metallicOrbTpl;
    } else {
      template = this.orbTpl;
    }

    let orb = template.cloneNode(true);
    orb.classList.add(`symbol-${symbol}`);
    orb.setAttribute('transform', `translate(${rx},${ry})`);
    orb.querySelector('.orb-fill')
      .setAttribute('fill', this.orbColors[symbol]);
    orb.appendChild(this.symbolTpls[symbol].cloneNode(true));

    this.$orbs.appendChild(orb);

    let object = {
      node: orb,
      symbol
    };

    orb.addEventListener('click', () => {
      this.onOrbClick(arrayIndex, object);
    });

    this.grid[arrayIndex] = object;
    return object;
  }

  /**
   * Build board background
   */
  buildBackground() {
    // Background hexagon
    let polygon = Svg.fromXML(`
      <polygon 
        points="43,-25 0,-50 -43,-25 -43,25 0,50 43,25"
        transform="translate(0,0) scale(10.7) rotate(30)"
        class="board-bg-hex"
        fill="#7e6c56" />
    `);
    this.$bg.appendChild(polygon);

    // -- Tile grid --

    // Grid is built in two passes - shadows must be placed first
    this.buf0 = [];
    this.buf1 = [];

    for (let y = 0; y < 6; y++) {
      for (let x = 0; x < 6 + y; x++) {
        this.placeTile(x, y);
      }
    }

    for (let y = 0; y < 5; y++) {
      for (let x = 0; x < 10 - y; x++) {
        this.placeTile(x + y + 1, 6 + y);
      }
    }

    this.buf0.forEach((elem) => {
      this.$bg.appendChild(elem);
    });

    // inner fill so the inner tiles don't have to use blur shadow
    // The color is precisely the shadow in the middle of the border shadows
    // superimposed over the background brown.
    let polygon2 = Svg.fromXML(`
      <polygon 
        points="43,-25 0,-50 -43,-25 -43,25 0,50 43,25"
        transform="translate(0,0) scale(9.3) rotate(30)"
        class="tile-shadow"
        fill="#372F26" />
    `);
    this.$bg.appendChild(polygon2);

    this.buf1.forEach((elem) => {
      this.$bg.appendChild(elem);
    });

    delete this.buf0;
    delete this.buf1;
  }

  /**
   * Init the orb template
   */
  initOrb() {
    this.orbTpl = Svg.fromXMLg(`
      <g transform="scale(0.75)">
        <circle
           r="50" cy="5" cx="0"
           fill="black"
           class="orb-shadow" />
        <circle
           r="55" cy="0" cx="0"
           fill="white"
           class="orb-glow"
           fill-opacity="0" />
        <circle
           r="49" cy="0" cx="0"
           fill="#9F9F9F"
           class="orb-background" />
        <circle
           r="50" cy="0" cx="0"
           fill="red"
           class="orb-fill"
           fill-opacity="0.80" />
        <circle
           cx="0" cy="0" r="50"
           fill="url(#radGradOrbDark)" />
        <ellipse
           cx="0" cy="36.5" rx="28" ry="13"
           fill="url(#radGradOrbBottom)" />
        <ellipse 
           ry="24" rx="35" cy="-23" cx="0"
           fill="url(#radGradOrbTop)" />
      </g>
    `, {
      'class': 'orb',
    });

    let metallicEffect = Svg.fromXML(`
      <g fill-opacity="0.3" transform="translate(-3,0) scale(0.75)">
        <path
           d="M -43.75,9.2857139 C -30.010378,16.025483 26.753132,-7.8871183 45.535714,11.964285 15.246713,-2.1819387 -29.821429,19.702381 -43.75,9.2857139 Z"
           fill="url(#linGradMetallicInlay)" />
        <path
           d="M -45.357143,-2.8766642 C -31.617521,3.8631045 25.145989,-20.049496 43.928571,-0.19809327 13.63957,-14.344317 -31.428572,7.5400025 -45.357143,-2.8766642 Z"
           fill="url(#linGradMetallicInlay)" />
        <path
           d="m -41.071429,-14.222551 c 13.08012,5.7255132 67.118982,-14.588524 85,2.275477 -28.835129,-12.017384 -71.74,6.5736044 -85,-2.275477 z"
           fill="url(#linGradMetallicInlay)" />
        <path
           d="M -41.785714,21.6174 C -28.925428,27.053125 24.205217,7.7672504 41.785714,23.777708 13.435209,12.368564 -28.748572,30.018599 -41.785714,21.6174 Z"
           fill="url(#linGradMetallicInlay)" />
        <path
           d="m -35.178572,32.540035 c 10.771863,4.131683 55.274455,-10.527466 70,1.642045 -23.746577,-8.672064 -59.08,4.743688 -70,-1.642045 z"
           fill="url(#linGradMetallicInlay)" />
        <path
           d="m -39.107144,-24.427576 c 12.33818,5.58062 63.311813,-14.219337 80.178571,2.217893 -27.199522,-11.713264 -67.670714,6.407248 -80.178571,-2.217893 z"
           fill="url(#linGradMetallicInlay)" />
        <path
           d="m -36.428571,-35.852822 c 10.771863,4.131683 55.274455,-10.527466 70,1.642045 -23.7465774,-8.672064 -59.08,4.743688 -70,-1.642045 z"
           fill="url(#linGradMetallicInlay)" />
      </g>
    `);

    this.metallicOrbTpl = this.orbTpl.cloneNode(true);
    this.metallicOrbTpl.appendChild(metallicEffect);
  }

  /**
   * Init the tile template
   */
  initTile() {
    const offsetX = -13.25;
    const offsetY = -283.75;
    const scale = 3.9;
    const scaleShadow = scale + 0.1;

    this.tileShadowTpl = Svg.fromXMLg(`
      <g transform="scale(${scaleShadow}) translate(${offsetX},${offsetY})" class="tile-shadow">
        <path
           transform="matrix(0.81746386,-0.47196298,0.47519507,0.823062,-132.56101,57.743511)"
           d="M 28.028064,282.66339 21.029591,294.7851 H 7.0326457 L 0.03417301,282.66339 7.0326457,270.54168 H 21.029591 Z"
           fill="#000000"
           fill-opacity="0.6"
           filter="url('#filterDropshadow')" />
      </g>
    `);


    /*
      paths:
        back-and-bottom
        top-left_right
        left
        top-right
        inner
        circle
    */
    this.tileTpl = Svg.fromXMLg(`
      <g transform="scale(${scale}) translate(${offsetX},${offsetY})">
        <path
           transform="matrix(0.81746386,-0.47196298,0.47519507,0.823062,-132.56101,57.743511)"
           d="M 28.028064,282.66339 21.029591,294.7851 H 7.0326457 L 0.03417301,282.66339 7.0326457,270.54168 H 21.029591 Z"
           fill="#9e9272" />
        <path
           transform="matrix(0.81746386,-0.47196298,0.47519507,0.823062,-132.56101,57.743511)"
           d="M 28.028064,282.66339 21.029591,294.7851 7.0326457,270.54168 H 21.029591 Z"
           fill="#d0c6ab" />
        <path
           transform="matrix(0.81746386,-0.47196298,0.47519507,0.823062,-132.56101,57.743511)"
           d="M 14.031114,282.66339 H 0.03417301 L 7.0326457,270.54168 Z"
           fill="#b4a682" />
        <path
           transform="matrix(0.81746386,-0.47196298,0.47519507,0.823062,-132.56101,57.743511)"
           d="m 28.028064,282.66339 h -13.99695 l 6.998477,-12.12171 z"
           fill="#d7d3c1" />
        <path
           transform="matrix(0.74967562,-0.43282542,0.43578949,0.75480954,-120.47135,76.486841)"
           d="m 28.343049,282.66339 -7.155965,12.3945 H 6.8751531 L -0.28081226,282.66339 6.8751531,270.2689 H 21.187084 Z"
           fill="#ab9f7e" />
        <circle
           r="10.155904"
           cy="283.77084"
           cx="13.229166"
           fill="url(#radGradSlotBg)" />
      </g>
    `, {class: 'tile'});
  }

  /**
   * Add a board tile to the image.
   *
   * @param x
   * @param y
   */
  placeTile(x, y) {
    const {rx, ry} = this.gridXyToCoord(x, y);

    /*
    // Debug circle
    this.$svg.appendChild(Svg.makeNode('circle', {
      r: this.TILE_W/2,
      cy: ry, cx: rx,
      fill: 'none',
      stroke: 'black',
      'stroke-width': 2,
    }));
    */

    if (isXyBorder(x, y)) {
      let polygon_shadow = this.tileShadowTpl.cloneNode(true);
      polygon_shadow.setAttribute('transform', `translate(${rx},${ry}),scale(1.1)`);
      this.buf0.push(polygon_shadow);
    }

    const index = xyToGridIndex(x, y);
    let tile = this.tileTpl.cloneNode(true);
    tile.setAttribute('transform', `translate(${rx},${ry})`);
    this.buf1.push(tile);

    tile.addEventListener('click', () => {
      this.onTileClick(index);
    });

    this.tiles[index] = tile;
  }

  /**
   * Init the orb glyph templates
   */
  initGlyphs() {
    let symbolPaths = {
      salt: 'm 13.229166,291.52894 c -3.193365,0.056 -6.2293596,-2.11498 -7.2925669,-5.11388 -1.1013996,-2.92841 -0.2242509,-6.48596 2.1908174,-8.50166 2.5380605,-2.28864 6.6278455,-2.54558 9.4211355,-0.56925 2.346844,1.54401 3.721353,4.41788 3.400731,7.21296 -0.300944,3.62792 -3.485132,6.74361 -7.122041,6.95045 -0.19901,0.0144 -0.398548,0.0214 -0.598076,0.0214 z m -6.1435917,-8.51297 c 4.0957287,0 8.1914567,0 12.2871847,0 -0.315436,-3.07409 -3.239662,-5.6097 -6.336645,-5.4279 -2.970056,0.0389 -5.6195586,2.4852 -5.9505397,5.4279 z m 6.1435917,6.94037 c 2.96606,0.0757 5.716029,-2.30671 6.114844,-5.23796 0.09389,-0.3732 -0.542301,-0.11772 -0.794471,-0.19272 -3.821322,0 -7.642643,0 -11.4639647,0 0.352809,3.02068 3.0769237,5.49713 6.1435917,5.43068 z',
      air: 'm 5.1145913,291.51845 c 2.7048586,-5.16509 5.4097167,-10.33018 8.1145757,-15.49527 2.704858,5.16509 5.409717,10.33018 8.114575,15.49527 -5.409717,0 -10.819434,0 -16.2291507,0 z m 6.0387527,-7.84199 c 1.383881,0 2.767763,0 4.151644,0 -0.69194,-1.35592 -1.383881,-2.71185 -2.075821,-4.06777 -0.691941,1.35592 -1.383882,2.71185 -2.075823,4.06777 z m -3.1661517,6.18553 c 3.4946497,0 6.9892987,0 10.4839477,0 -0.789791,-1.54463 -1.579581,-3.08927 -2.369372,-4.6339 -1.915068,0 -3.830136,0 -5.745204,0 -0.7897906,1.54463 -1.5795811,3.08927 -2.3693717,4.6339 z',
      fire: 'm 5.1145923,291.51845 c 2.7048582,-5.16509 5.4097167,-10.33018 8.1145747,-15.49527 2.704858,5.16509 5.409716,10.33018 8.114574,15.49527 -5.409716,0 -10.819432,0 -16.2291487,0 z m 2.872601,-1.65646 c 3.4946487,0 6.9892977,0 10.4839467,0 -1.747324,-3.41777 -3.494649,-6.83553 -5.241973,-10.2533 -1.747325,3.41777 -3.4946491,6.83553 -5.2419737,10.2533 z',
      water: 'm 13.229169,291.51845 c -2.704859,-5.16509 -5.409717,-10.33018 -8.1145755,-15.49527 5.4097155,0 10.8194315,0 16.2291465,0 -2.704857,5.16509 -5.409714,10.33018 -8.114571,15.49527 z m 0,-3.58551 c 1.747323,-3.41777 3.494647,-6.83553 5.24197,-10.2533 -3.494648,0 -6.989296,0 -10.4839445,0 1.7473248,3.41777 3.4946495,6.83553 5.2419745,10.2533 z',
      earth: 'm 13.229167,291.51845 c -2.704858,-5.16509 -5.409717,-10.33018 -8.1145755,-15.49527 5.4097165,0 10.8194335,0 16.2291505,0 -2.704858,5.16509 -5.409717,10.33018 -8.114575,15.49527 z m -2.872601,-9.2049 c 1.915068,0 3.830135,0 5.745203,0 0.789791,-1.54464 1.579581,-3.08927 2.369372,-4.63391 -3.494649,0 -6.989298,0 -10.4839475,0 0.7897908,1.54464 1.5795817,3.08927 2.3693725,4.63391 z m 2.872601,5.61939 c 0.691941,-1.35592 1.383881,-2.71185 2.075822,-4.06777 -1.383881,0 -2.767762,0 -4.151643,0 0.69194,1.35592 1.383881,2.71185 2.075821,4.06777 z',
      mercury: 'm 15.732407,279.01472 c 1.457188,0.91755 2.327793,2.6639 2.118676,4.38195 -0.130888,2.01739 -1.899178,3.73377 -3.900128,3.89053 0,0.71327 0,1.42654 0,2.13981 0.737153,-0.007 1.474306,-0.0132 2.211459,-0.0198 0,0.34126 0,0.68253 0,1.02379 -0.750804,0.003 -1.501607,0.007 -2.252411,0.01 0.0034,0.68938 0.0068,1.37875 0.01024,2.06813 -0.440244,0 -0.880489,0 -1.320733,0 -0.0034,-0.69279 -0.0068,-1.38558 -0.01024,-2.07837 -0.689374,0 -1.378747,0 -2.068121,0 0,-0.33444 0,-0.66888 0,-1.00332 0.689374,0 1.378747,0 2.068121,0 -0.0068,-0.71327 -0.01365,-1.42654 -0.02047,-2.13981 -2.15006,-0.13324 -4.015907,-2.07311 -3.9813417,-4.24183 -0.084702,-1.60894 0.7922593,-3.17204 2.1384617,-4.03065 -1.3321084,-0.8479 -2.2089181,-2.39019 -2.1397915,-3.98265 0.4846095,0 0.969219,0 1.4538285,0 -0.129447,1.801 1.552959,3.41833 3.338775,3.2762 1.681768,-0.0229 3.155505,-1.58904 3.039642,-3.2762 0.48461,0 0.969221,0 1.453831,0 0.06599,1.601 -0.803406,3.12723 -2.139798,3.98222 z m -2.303602,0.71667 c -1.758778,-0.16425 -3.4619579,1.37986 -3.388854,3.16361 -0.083477,1.71263 1.535734,3.14586 3.204567,3.07148 1.719365,0.0703 3.342464,-1.48587 3.170211,-3.23621 -0.02747,-1.57245 -1.423299,-2.948 -2.985924,-2.99888 z',
      lead: 'm 9.5126894,279.50182 c -0.3617507,0 -0.7235013,0 -1.085252,0 0,-0.51532 0,-1.03065 0,-1.54597 0.3617507,0 0.7235013,0 1.085252,0 0,-0.6143 0,-1.2286 0,-1.8429 0.5187366,0 1.0374736,0 1.5562106,0 0,0.6143 0,1.2286 0,1.8429 0.607468,0 1.214936,0 1.822404,0 0,0.51532 0,1.03065 0,1.54597 -0.607468,0 -1.214936,0 -1.822404,0 0,1.32073 0,2.64145 0,3.96218 1.537514,-1.62806 4.417116,-1.42308 5.808997,0.28791 1.184192,1.31365 1.566419,3.41483 0.604294,4.96063 -0.519367,0.80942 -0.90468,1.73924 -0.884648,2.71614 -0.515324,0 -1.030647,0 -1.545971,0 -0.04659,-1.19876 0.527295,-2.27553 1.100009,-3.28428 0.92482,-1.59336 -0.125612,-3.92412 -1.965862,-4.19999 -1.635761,-0.31517 -3.124345,1.21479 -3.11374,2.80432 -0.0071,1.55992 -0.0011,3.11998 -0.0031,4.67995 -0.518737,0 -1.037474,0 -1.5562106,0 0,-3.97562 0,-7.95124 0,-11.92686 z',
      tin: 'm 11.836769,285.99296 c 1.581694,0.0451 3.054161,-1.39068 2.989562,-2.99956 0.06903,-1.76826 -1.701886,-3.28981 -3.441524,-2.95861 -1.4247698,0.18442 -2.5932003,1.53599 -2.5376015,2.97841 -0.5119117,0 -1.0238233,0 -1.535735,0 -0.085695,-2.3801 2.0195976,-4.57675 4.4081085,-4.55471 2.311495,-0.14461 4.495576,1.78385 4.652567,4.08999 0.127445,1.23443 -0.250024,2.52397 -1.095332,3.44405 0.774693,0 1.549385,0 2.324078,0 0,-3.2933 0,-6.58661 0,-9.87991 0.515324,0 1.030647,0 1.545971,0 0,5.10547 0,10.21093 0,15.3164 -0.515324,0 -1.030647,0 -1.545971,0 0,-1.29684 0,-2.59368 0,-3.89052 -3.429807,0 -6.859614,0 -10.2894215,0 0,-0.51532 0,-1.03065 0,-1.54597 1.5084328,1.4e-4 3.0168655,2.9e-4 4.5252985,4.3e-4 z',
      iron: 'm 11.237833,281.77434 c -2.1129725,-0.0765 -4.0685065,1.7834 -4.0429529,3.90895 -0.1246246,1.8905 1.3843212,3.64089 3.2341329,3.92585 1.98814,0.41339 4.242048,-0.80512 4.735939,-2.82798 0.42126,-1.59401 -0.247897,-3.38375 -1.626517,-4.29444 -0.666348,-0.47624 -1.483823,-0.71835 -2.300602,-0.71238 z m 4.218149,8.01654 c -2.219729,2.26802 -6.2673049,2.23092 -8.4717123,-0.0411 -2.1829952,-2.03247 -2.1495359,-5.81571 -0.085686,-7.91896 1.2047257,-1.38003 3.0857918,-2.10369 4.9042713,-1.84319 1.04112,0.0976 2.126139,0.37802 2.915975,1.10076 1.232001,-1.18763 2.464001,-2.37526 3.696002,-3.56289 -1.266128,0 -2.532257,0 -3.798385,0 0.477784,-0.48461 0.955568,-0.96921 1.433352,-1.45382 1.685895,0 3.371789,0 5.057684,0 0,1.67906 0,3.35812 0,5.03718 -0.488023,0.48803 -0.976045,0.97606 -1.464068,1.46409 0,-1.27296 0,-2.54591 0,-3.81887 -1.221761,1.18763 -2.443522,2.37526 -3.665283,3.56289 1.692564,2.15746 1.51732,5.5939 -0.52215,7.47392 z',
      copper: 'm 13.229167,276.61943 c -2.112981,-0.0765 -4.0685271,1.78337 -4.04295,3.90894 -0.1277359,1.92476 1.447325,3.68809 3.331873,3.93182 2.025344,0.379 4.29148,-0.9459 4.680309,-3.02816 0.350755,-1.61444 -0.419466,-3.36035 -1.832442,-4.20966 -0.633357,-0.40672 -1.386458,-0.60708 -2.13679,-0.60294 z m -4.1567215,8.05748 c -2.3050816,-2.02524 -2.2795847,-5.96633 -0.1160579,-8.08364 2.0292494,-2.23826 5.8431584,-2.42533 8.0888084,-0.41068 1.604759,1.29217 2.357262,3.46519 1.988157,5.48009 -0.363367,2.37123 -2.511902,4.28305 -4.882744,4.48852 0,0.90097 0,1.80194 0,2.70291 0.928266,0 1.856531,0 2.784797,0 0,0.42318 0,0.84635 0,1.26953 -0.928266,0 -1.856531,0 -2.784797,0 0,0.87708 0,1.75415 0,2.63123 -0.614294,0 -1.228589,0 -1.842883,0 0,-0.87708 0,-1.75415 0,-2.63123 -0.928266,0 -1.856532,0 -2.7847978,0 0,-0.42318 0,-0.84635 0,-1.26953 0.9282658,0 1.8565318,0 2.7847978,0 0,-0.90438 0,-1.80876 0,-2.71314 -1.207048,-0.10173 -2.3584982,-0.63012 -3.2352805,-1.46406 z',
      silver: 'm 9.9755063,275.73179 c -0.6014253,0.18082 -1.8508344,-0.12736 -1.9078968,0.74722 0.1176844,0.4442 0.7067667,0.30018 1.0195284,0.4996 2.4102181,0.84689 4.3760261,3.03219 4.6611691,5.61268 0.329015,2.2322 -0.292857,4.61262 -1.845188,6.27899 -0.950816,1.0625 -2.3118271,1.60982 -3.6314341,2.04088 -0.3010461,0.22955 -0.1186419,0.78 0.2735733,0.75652 2.3443258,0.46012 4.8961488,-0.14011 6.7090618,-1.72152 1.916707,-1.49245 3.236515,-3.83656 3.14282,-6.30499 -0.01445,-1.96756 -0.864897,-3.88727 -2.285751,-5.24448 -1.577433,-1.67996 -3.811286,-2.7316 -6.1358827,-2.6649 z m 2.7602927,2.03153 c 1.072404,0.39734 1.870102,1.27809 2.623097,2.10375 1.308542,1.47534 1.847166,3.59612 1.292761,5.50102 -0.534285,1.8245 -1.854209,3.45004 -3.574828,4.26864 -0.191682,0.0234 0.647922,-0.682 0.817217,-1.00958 1.833102,-2.3961 2.027928,-5.83879 0.732872,-8.5234 -0.452679,-0.90448 -1.108696,-1.7015 -1.891119,-2.34043 z',
      gold: 'm 13.219492,275.7189 c -2.986621,0.01 -5.9729442,1.75519 -7.2157492,4.51399 -1.4447671,2.86838 -0.9743467,6.60206 1.2502049,8.94534 1.6486642,1.87365 4.2016723,2.85934 6.6817843,2.61607 3.746213,-0.22926 7.026658,-3.46775 7.307955,-7.20938 0.345627,-3.27431 -1.477563,-6.74322 -4.539965,-8.07025 -1.086444,-0.51129 -2.281439,-0.79866 -3.48423,-0.79577 z m 0,1.55427 c 2.485623,0.002 4.974789,1.48839 5.934079,3.82276 1.125698,2.44571 0.57943,5.58664 -1.449826,7.40023 -2.35696,2.37064 -6.611547,2.37064 -8.9685065,0 -2.1169053,-1.88824 -2.608931,-5.21179 -1.3089579,-7.69696 1.0280707,-2.17935 3.4218714,-3.51551 5.7932114,-3.52603 z m 0.0066,4.19203 c -1.370356,-0.0494 -2.523547,1.30386 -2.277873,2.64791 0.150011,1.3618 1.658854,2.29772 2.951679,1.86852 1.391568,-0.36367 2.080533,-2.14843 1.33192,-3.36846 -0.389128,-0.716 -1.20151,-1.14581 -2.005726,-1.14797 z',
      mors: 'm 11.975898,274.8189 0.358077,0.35808 v 2.86461 H 9.4693607 v 1.79038 h 2.8646143 v 3.93885 H 6.9628227 l 6.4453823,8.95192 6.087306,-8.95192 H 14.12436 v -3.93885 h 2.864613 v -1.79038 H 14.12436 v -2.86461 l 0.358076,-0.35808 z m -1.790384,10.38422 h 6.445383 l -3.222692,4.65501 z',
      vitae: 'm 14.482436,292.72274 -0.358077,-0.35808 v -2.86461 h 2.864614 v -1.79038 h -2.864614 v -3.93885 h 5.371152 l -6.445382,-8.95192 -6.0873063,8.95192 h 5.3711513 v 3.93885 H 9.4693607 v 1.79038 h 2.8646133 v 2.86461 l -0.358076,0.35808 z M 16.27282,282.33852 H 9.8274367 l 3.2226923,-4.65501 z',
    };

    this.orbColors = {
      salt: '#f2e7b4',
      air: '#9ee0ff',
      fire: '#ff540b',
      water: '#0fdac3',
      earth: '#99ff11',

      mercury: '#f2e7b4',
      lead: '#728686',
      tin: '#c5be9b',
      iron: '#b59e97',
      copper: '#f5a581',
      silver: '#cfcac3',
      gold: '#ffba50',

      mors: '#433c29',
      vitae: '#F5A79E',
    };

    this.symbolTpls = {};

    for (let s in symbolPaths) {
      if (symbolPaths.hasOwnProperty(s)) {
        let path = symbolPaths[s];
        this.symbolTpls[s] = Svg.fromXMLg(`
            <path
              d="${path}"
              transform="scale(2) translate(-13,-283.5)"
              stroke="black"
              stroke-width="2"
              stroke-opacity="0.3"
              paint-order="stroke"
              fill="white"
            />
        `);
      }
    }
  }

  /**
   * Remove all boards on the board
   */
  removeAllOrbs() {
    Object.keys(this.grid).forEach((n) => {
      this.removeOrbByIndex(+n);
    })
  }

  /**
   * Get orb by array index
   *
   * @param {Number} n
   * @returns {object} grid object
   */
  getOrbByIndex(n) {
    return this.grid[n];
  }

  /**
   * Check if a cell is available for selection in-game
   * (checking surroundings only). This should work the same regardless
   * of whether the cell is occupied or not.
   *
   * @param n
   * @return {boolean}
   */
  isAvailable(n) {
    return this.getCellInfo(n).freeSequence >= 3;
  }

  /**
   * Get info about a cell surroundings and position
   *
   * neighbours - number of occupied neighbouring cells
   * centerWeight - distance from the board center
   * freeSequence - number of contiguous free tiles in the direct neighbourhood
   *
   * @param n
   * @return {{neighbours: number, centerWeight: number, freeSequence: number}}
   */
  getCellInfo(n) {
    let {x, y} = gridIndexToXy(n);

    let freeSpaces = [
      isXyOutside(x - 1, y) || !this.grid[n - 1],
      isXyOutside(x - 1, y - 1) || !this.grid[n - 12],
      isXyOutside(x, y - 1) || !this.grid[n - 11],
      isXyOutside(x + 1, y) || !this.grid[n + 1],
      isXyOutside(x + 1, y + 1) || !this.grid[n + 12],
      isXyOutside(x, y + 1) || !this.grid[n + 11],
    ];

    let nOccupied = 0;
    for (let i = 0; i < 6; i++) {
      if (!freeSpaces[i]) {
        nOccupied++;
      }
    }

    let freeSequence = 0;
    let maxFreeSequence = 0;
    for (let i = 0; i < 12; i++) {
      if (freeSpaces[i % 6]) {
        freeSequence++;
        if (freeSequence >= 6) {
          maxFreeSequence = freeSequence;
          break;
        }
        if (freeSequence > maxFreeSequence) {
          maxFreeSequence = freeSequence;
        }
      } else {
        freeSequence = 0;
      }
    }

    let { rx, ry } = this.gridXyToCoord(x, y);

    return {
      neighbours: nOccupied,
      freeSequence: maxFreeSequence,
      centerWeight: Math.round(Math.sqrt(Math.pow(rx, 2) + Math.pow(ry, 2))),
    };
  }

  /**
   * Build the game GUI (buttons, links)
   */
  buildGui() {
    const addButton = (x, y, text, classes='') => {
      let { rx, ry } = this.guiXyToCoord(x, y);
      let button = Svg.fromXML(`<text class="button-text${classes?' '+classes:''}" x="${rx}" y="${ry}">${text}</text>`);
      this.$root.appendChild(button);
      return button;
    };

    const y0 = 0.05;
    const x0 = 0;
    const ysp = 0.75;
    const ysp2 = 0.6;

    this.buttons.randomize = addButton(x0, y0, 'Randomize');
    this.buttons.restart = addButton(x0, y0 + ysp, 'Try Again', 'disabled');
    this.buttons.undo = addButton(x0, y0 + ysp*2, 'Undo', 'disabled');
    this.buttons.working = addButton(x0, y0 + ysp*3, 'Working…', 'working');

    const cfgy0 = 10.5;

    this.buttons.optFancy = addButton(x0, cfgy0, 'Effects:', 'config');
    this.buttons.optBlockedEffect = addButton(x0, cfgy0+ysp2, 'Dim Blocked:', 'config');
    this.buttons.toggleFullscreen = addButton(x0, cfgy0+ysp2*-1.5, 'Fullscreen', 'config');

    this.buttons.btnAbout = addButton(x0, cfgy0+ysp2*-2.5, 'Help', 'config');

    let youWin = Svg.fromXML(`<text class="you-win" opacity="0" x="0" y="0">Good work!</text>`);
    this.$root.appendChild(youWin);
    this.youWin = youWin;
  }

  /**
   * Update toggles
   *
   * @param cfg
   */
  updateSettingsGUI(cfg) {
    this.buttons.optFancy.textContent = 'Effects: '+(cfg.svgEffects ? 'On' : 'Off');
    this.buttons.optBlockedEffect.textContent = 'Dim Blocked: '+(cfg.dimBlocked ? 'On' : 'Off');
  }
}

/**
 * Random number generator
 *
 * Uses Mullbery32 from https://stackoverflow.com/a/47593316/2180189
 */
class Rng {
  /**
   * Construct with a given or random seed
   *
   * @param {Number|null} seed
   */
  constructor(seed = null) {
    this.seed = null;

    if (seed === null) {
      seed = +new Date;
    }

    this.setSeed(+seed);
  }

  /**
   * Set seed for following rolls
   *
   * @param {Number} seed
   */
  setSeed(seed) {
    seed = +seed;
    this.seed = seed;
    this.state = seed;
  }

  /**
   * Get a pseudo-random number
   *
   * @returns {Number}
   */
  next() {
    let t = this.state += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  }

  /**
   * Get next int, inclusive
   *
   * @param {Number} max
   * @return {Number}
   */
  nextInt(max) {
    return Math.floor((max + 1) * this.next());
  }

  /**
   * Shuffle an array.
   * The array is shuffled in place.
   *
   * @return the array
   */
  arrayShuffle(a) {
    let j, x, i;
    for (i = a.length - 1; i > 0; i--) {
      j = this.nextInt(i);
      x = a[i];
      a[i] = a[j];
      a[j] = x;
    }
    return a;
  }

  /**
   * Choose a random member of an array
   *
   * @param array
   * @return {number}
   */
  arrayChoose(array) {
    return array[Math.floor(this.next() * array.length)];
  }

  /**
   * Sneak items in the given order into another array
   *
   * @param array - the modified array
   * @param startKeepout - how many leading lements to leave untouched
   * @param endKeepout - how many trailing elements to leave untouched
   * @param items - items to sneak in
   * @return the modified array
   */
  arraySneak(array, startKeepout, endKeepout, items) {
    let positions = [];
    for (let i = 0; i < items.length; i++) {
      positions.push(startKeepout + this.nextInt(array.length - startKeepout - endKeepout))
    }
    positions.sort((a, b) => a - b);
    // inject them into the array
    items.forEach((pair, i) => {
      array.splice(positions[i] + i, 0, pair);
    });

    return array;
  }
}

class SettingsStorage {
  constructor() {
    // this object is never overwritten, references are stable
    this.defaults = {
      version: 1,
      log: 'info',
      retryTemplate: 70,
      attemptTemplates: 20,
      svgEffects: false,
      dimBlocked: true,
      logSolution: false,
      highlightTemplate: false,
    };
    this.settings = Object.assign({}, this.defaults);
  }

  load() {
    let saved = localStorage.getItem('sigmar_settings');
    if (saved) {
      let parsed;
      try {
        parsed = JSON.parse(saved);

        // XXX some validation / version conversion could be done here

        delete parsed.version;

        Object.assign(this.settings, parsed);
      } catch (e) {
        console.error("Error loading settings:", e);
      }
    }

    return this.settings;
  }

  update(update) {
    Object.assign(this.settings, update);
    return this.settings;
  }

  save() {
    let changed = Object.entries(this.settings).reduce((acu, [k, v]) => {
      if (this.defaults[k] !== v) {
        acu[k] = v;
      }
      return acu;
    }, {});

    localStorage.setItem('sigmar_settings', JSON.stringify(changed));
  }
}

class Nav {
  /**
   * Replace URL in the address bar
   * @param new_url
   */
  static replaceUrl(new_url) {
    history.replaceState(null, null, new_url);
  }

  /**
   * Set URL args (GET).
   *
   * @param args - arguments to set, or delete (when the value is null)
   */
  static setUrlArgs(args) {
    let url = new URL(location.href);
    let query = new URLSearchParams(url.search);
    for (let [k, v] of Object.entries(args)) {
      if (v === null) {
        query.delete(k);
      } else {
        query.set(k, v);
      }
    }
    url.search = query.toString();
    history.replaceState(null, null, url.href);
  }

  /**
   * Get URL arguments
   *
   * @return {object}
   */
  static getUrlArgs() {
    let url = new URL(location.href);
    let query = new URLSearchParams(url.search);
    let params = {};
    for (const [key, value] of query) {
      params[key] = value;
    }
    return params;
  }
}

class Game {
  /**
   * Init the game
   */
  constructor() {
    this.LOGLEVELS = ['error', 'warn', 'info', 'debug', 'trace'];

    this.settingsStore = new SettingsStorage();
    this.cfg = this.settingsStore.load();
    this.applyLogFilter();

    this.get_opts = {
      url_seed: true,
      template: null,
      template_flip: null,
    };

    let args = Object.assign({
      seed: null,
      debug: null,
      trace: null,
      log: null,
      pretty: null,
      rnd: null,
      template: null,
    }, Nav.getUrlArgs());

    this.board = new Board();
    this.rng = new Rng();

    // Debug can be toggled via the debug=0/1 GET arg
    if (args.debug !== null) {
      this.setCfg({ log: (!!+args.debug) ? 'debug' : 'info' });
    }
    if (args.trace !== null) {
      this.setCfg({ log: (!!+args.trace) ? 'trace' : 'debug' });
    }
    if (args.log !== null) {
      this.setCfg({ log: args.log });
    }
    if (args.rnd !== null) {
      this.get_opts.url_seed = !!!+args.rnd;
    }
    if (args.template !== null) {
      let tpl = args.template;
      this.get_opts.template = tpl;
      this.get_opts.template_flip = false;
      if (tpl.endsWith('_flip')) {
        this.get_opts.template_flip = true;
        this.get_opts.template = tpl.substring(0, tpl.length - '_flip'.length);
      }
    }

    // Toggle GPU intensive effects via the pretty=0/1 GET arg
    if (args.pretty !== null) {
      this.setCfg({ svgEffects: !!+args.pretty });
    }

    this.info("Game settings:", this.cfg);

    this.layoutTemplates = {
      // templates apparently all have 55 items

      //'wheel':     [0,1,2,3,4,5,11,12,13,14,15,16,17,22,23,24,27,28,29,33,34,36,38,40,41,44,45,48,49,52,53,55,56,57,58,59,60,61,62,63,64,65,67,68,71,72,75,76,79,80,82,84,86,87,91,92,93,96,97,98,103,104,105,106,107,108,109,115,116,117,118,119,120],
      'beyblade': [0, 1, 2, 3, 4, 5, 12, 14, 15, 23, 26, 34, 35, 37, 38, 39, 40, 46, 47, 48, 50, 51, 52, 55, 58, 60, 61, 64, 65, 67, 68, 69, 70, 71, 73, 76, 79, 80, 83, 84, 85, 86, 87, 91, 94, 95, 97, 98, 103, 104, 105, 106, 109, 115, 120],
      'tulip': [4, 14, 15, 16, 23, 24, 25, 26, 27, 28, 34, 35, 36, 37, 39, 40, 45, 46, 47, 48, 49, 50, 51, 52, 56, 57, 59, 60, 61, 62, 63, 67, 68, 69, 70, 71, 72, 73, 74, 80, 81, 82, 83, 85, 86, 93, 94, 95, 96, 97, 105, 106, 107, 108, 109],
      'alien': [3, 4, 14, 15, 16, 22, 25, 26, 27, 28, 34, 35, 36, 37, 38, 39, 40, 41, 45, 46, 48, 49, 51, 56, 57, 58, 59, 60, 61, 62, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 81, 82, 84, 85, 86, 94, 95, 96, 97, 98, 106, 107, 108, 109, 117],
      'cube': [1, 5, 12, 13, 14, 15, 16, 17, 23, 27, 29, 33, 34, 35, 36, 37, 38, 40, 44, 48, 49, 51, 52, 55, 56, 57, 58, 59, 60, 61, 62, 64, 68, 70, 71, 72, 73, 75, 76, 80, 82, 84, 86, 92, 94, 96, 97, 103, 104, 105, 106, 108, 118, 119, 120],
      'star': [3, 14, 15, 22, 23, 24, 25, 26, 27, 34, 35, 36, 37, 38, 39, 40, 41, 46, 47, 48, 49, 50, 51, 52, 57, 58, 59, 60, 61, 62, 63, 68, 69, 70, 71, 72, 73, 74, 79, 80, 81, 82, 83, 84, 85, 86, 93, 94, 95, 96, 97, 98, 105, 106, 117],
      'flower': [3, 11, 12, 13, 14, 15, 23, 25, 27, 28, 34, 36, 37, 38, 39, 40, 45, 46, 47, 48, 49, 50, 52, 53, 57, 58, 59, 60, 61, 62, 64, 68, 70, 71, 72, 73, 74, 75, 79, 80, 81, 82, 83, 84, 86, 92, 95, 96, 97, 98, 104, 105, 106, 107, 116],
      'windmill': [4, 11, 12, 13, 14, 15, 16, 23, 24, 25, 27, 28, 34, 37, 39, 40, 45, 46, 47, 48, 49, 50, 52, 53, 56, 57, 59, 60, 61, 63, 64, 67, 68, 70, 71, 72, 73, 74, 75, 80, 81, 83, 86, 92, 93, 95, 96, 97, 104, 105, 106, 107, 108, 109, 116],
      'propeller': [1, 2, 3, 4, 13, 14, 15, 16, 22, 25, 28, 34, 36, 37, 38, 39, 40, 41, 45, 46, 47, 48, 50, 56, 58, 60, 61, 62, 67, 68, 70, 71, 73, 74, 75, 76, 79, 80, 81, 82, 83, 84, 86, 87, 91, 92, 95, 97, 98, 103, 106, 107, 108, 109, 117],
      'garden': [0, 1, 2, 3, 4, 5, 11, 12, 13, 14, 15, 16, 17, 22, 23, 28, 29, 33, 34, 40, 41, 44, 45, 52, 53, 55, 56, 60, 64, 65, 67, 68, 75, 76, 79, 80, 86, 87, 91, 92, 97, 98, 103, 104, 105, 106, 107, 108, 109, 115, 116, 117, 118, 119, 120],
      'windmill2': [1, 12, 13, 14, 15, 16, 17, 23, 24, 26, 27, 28, 34, 35, 36, 37, 38, 40, 44, 45, 47, 50, 51, 52, 56, 57, 58, 60, 62, 63, 64, 68, 69, 70, 73, 75, 76, 80, 82, 83, 84, 85, 86, 92, 93, 94, 96, 97, 103, 104, 105, 106, 107, 108, 119],
      'bird': [2, 3, 4, 14, 15, 25, 27, 28, 29, 33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 47, 48, 49, 50, 51, 57, 58, 59, 60, 61, 62, 67, 68, 70, 71, 72, 73, 74, 79, 80, 81, 82, 83, 84, 86, 87, 91, 94, 95, 96, 97, 98, 106, 107, 109, 118],
      'strider': [1, 2, 3, 4, 11, 12, 13, 14, 15, 24, 25, 26, 36, 37, 38, 47, 48, 49, 50, 53, 58, 59, 60, 61, 62, 63, 64, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 79, 80, 81, 82, 83, 84, 85, 86, 87, 91, 92, 93, 97, 98, 103, 104, 109, 116],
      'campfire': [0, 1, 2, 3, 4, 5, 11, 15, 17, 22, 23, 24, 25, 26, 27, 29, 33, 35, 39, 41, 44, 46, 51, 52, 53, 55, 57, 60, 63, 65, 67, 68, 69, 74, 76, 79, 81, 85, 87, 91, 93, 94, 95, 96, 97, 98, 103, 105, 109, 115, 116, 117, 118, 119, 120],
      'skillet': [0, 1, 2, 5, 11, 13, 17, 22, 25, 26, 27, 28, 29, 33, 37, 39, 41, 44, 45, 46, 47, 48, 49, 50, 53, 55, 57, 59, 60, 61, 65, 69, 70, 71, 72, 73, 74, 75, 76, 81, 83, 85, 87, 91, 92, 95, 96, 103, 107, 115, 116, 117, 118, 119, 120],
      'digger': [2, 3, 4, 5, 16, 17, 22, 27, 28, 29, 33, 34, 35, 36, 37, 38, 39, 40, 41, 44, 45, 46, 47, 48, 50, 51, 55, 56, 57, 58, 60, 61, 62, 67, 70, 71, 73, 79, 82, 83, 84, 87, 91, 94, 95, 96, 98, 106, 107, 108, 109, 117, 118, 119, 120],
      'chestnut': [3, 4, 12, 13, 14, 15, 16, 23, 25, 27, 28, 34, 36, 38, 39, 40, 45, 46, 47, 48, 49, 50, 52, 56, 57, 58, 59, 60, 61, 62, 64, 67, 68, 71, 72, 74, 75, 79, 80, 81, 82, 83, 84, 86, 92, 95, 96, 97, 98, 104, 105, 106, 107, 108, 109],
      'manta': [0, 5, 11, 16, 22, 23, 24, 25, 26, 27, 28, 29, 33, 34, 35, 39, 40, 44, 45, 46, 47, 51, 52, 56, 57, 58, 59, 60, 61, 62, 63, 64, 68, 69, 73, 74, 75, 76, 80, 81, 85, 86, 87, 91, 92, 93, 94, 95, 96, 97, 98, 104, 109, 115, 120],
      'pyramids': [3, 4, 14, 15, 16, 23, 25, 26, 28, 34, 35, 36, 37, 38, 39, 40, 45, 46, 47, 48, 49, 50, 51, 52, 56, 58, 59, 60, 61, 62, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 81, 82, 83, 84, 85, 86, 94, 95, 97, 98, 105, 106, 107, 108, 109],
      'bigwheel': [0, 1, 2, 3, 4, 5, 11, 13, 17, 22, 25, 28, 29, 33, 36, 37, 38, 39, 41, 44, 45, 46, 47, 50, 53, 55, 58, 60, 62, 65, 67, 70, 73, 74, 75, 76, 79, 81, 82, 83, 84, 87, 91, 92, 95, 98, 103, 107, 109, 115, 116, 117, 118, 119, 120],
      'handshake': [0, 1, 2, 3, 4, 5, 11, 12, 13, 15, 16, 22, 23, 24, 25, 27, 33, 34, 35, 36, 38, 46, 48, 49, 50, 58, 59, 60, 61, 62, 70, 71, 72, 74, 82, 84, 85, 86, 87, 93, 95, 96, 97, 98, 104, 105, 107, 108, 109, 115, 116, 117, 118, 119, 120],
      'thinwheel': [0, 1, 2, 3, 4, 5, 11, 12, 16, 17, 22, 24, 27, 29, 33, 36, 38, 41, 44, 48, 49, 53, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 67, 71, 72, 76, 79, 82, 84, 87, 91, 93, 96, 98, 103, 104, 108, 109, 115, 116, 117, 118, 119, 120],
      'heavywheel': [12, 13, 14, 15, 16, 23, 24, 25, 27, 28, 34, 36, 37, 38, 39, 40, 45, 46, 47, 48, 49, 50, 52, 56, 57, 58, 59, 60, 61, 62, 63, 64, 68, 70, 71, 72, 73, 74, 75, 80, 81, 82, 83, 84, 86, 92, 93, 95, 96, 97, 104, 105, 106, 107, 108],
      'virus': [2, 3, 13, 14, 15, 22, 24, 26, 27, 34, 35, 36, 37, 38, 39, 40, 41, 46, 47, 48, 49, 50, 51, 57, 58, 59, 60, 61, 62, 63, 68, 69, 70, 71, 72, 73, 75, 79, 80, 82, 83, 84, 85, 86, 87, 91, 92, 93, 94, 95, 96, 97, 98, 106, 117],
      'frisbee': [0, 11, 12, 13, 14, 15, 22, 25, 26, 27, 28, 29, 33, 34, 36, 38, 39, 40, 41, 45, 46, 47, 49, 50, 53, 57, 58, 59, 60, 62, 64, 65, 68, 69, 72, 74, 75, 80, 81, 82, 83, 84, 85, 86, 92, 95, 96, 97, 104, 106, 107, 115, 116, 117, 118],
    };

    this.applySettings();
    this.installButtonHandlers();

    // this.board.testGraphics();
    // window.orb = this.board.getOrbByIndex(0).node;
    // return;

    // Defer start to give browser time to render the background
    setTimeout(() => {
      this.newGameWithLoader(args.seed)
    }, 50);
  }

  applyLogFilter() {
    let index = this.LOGLEVELS.indexOf(this.cfg.log);
    for (let level of this.LOGLEVELS) {
      this['logging_' + level] = index >= this.LOGLEVELS.indexOf(level);
    }
  }

  trace(...args) {
    if (this.logging_trace) console.debug(...args);
  }

  debug(...args) {
    if (this.logging_debug) console.log(...args);
  }

  info(...args) {
    if (this.logging_info) console.info(...args);
  }

  warn(...args) {
    if (this.logging_warn) console.warn(...args);
  }

  error(...args) {
    if (this.logging_error) console.error(...args);
  }

  applySettings() {
    this.board.$svg.classList.toggle('cfg-no-anim', !this.cfg.svgEffects);
    this.board.$svg.classList.toggle('cfg-anim', this.cfg.svgEffects);

    this.board.$svg.classList.toggle('cfg-no-blur', !this.cfg.svgEffects);
    this.board.$svg.classList.toggle('cfg-blur', this.cfg.svgEffects);

    this.board.$svg.classList.toggle('cfg-fade-disabled', this.cfg.dimBlocked);
    this.board.$svg.classList.toggle('cfg-no-fade-disabled', !this.cfg.dimBlocked);

    this.applyLogFilter();
    this.board.updateSettingsGUI(this.cfg);
  }

  setCfg(update) {
    this.settingsStore.update(update);
    this.applySettings();
    this.settingsStore.save();
  }

  getCfg(key) {
    return this.cfg[key];
  }

  /**
   * Show a selected template, for debug
   *
   * @param {String} name - template name
   * @param {boolean} flip - flip horizontally
   */
  showTemplate(name, flip = false) {
    this.board.removeAllOrbs();

    for (let n of this.getTemplate(name, flip).positions) {
      this.board.placeOrbByIndex(n, 'lead');
    }
  }

  /**
   * Show a random template, for debug
   */
  showRandomTemplate() {
    this.board.removeAllOrbs();
    for (let n of this.getRandomTemplate().positions) {
      this.board.placeOrbByIndex(n, 'lead');
    }
  }

  /**
   * Get a template - a sequence of numbers that are allowed as orb positions
   *
   * @param {String} name
   * @param {boolean} flipped
   * @returns {{name: String, flipped: Boolean, positions: Number[]}}
   */
  getTemplate(name, flipped) {
    let positions = this.layoutTemplates[name].slice(0); // this slice takes a copy so the array is not corrupted by later manipulations

    if (flipped) {
      positions = this.flipTemplate(positions);
    }

    return {
      basename: name,
      name: name+(flipped?'_flip':''),
      flipped,
      positions,
    };
  }

  /**
   * Get a random and randomly flipped template
   *
   * @returns {{name: String, flipped: Boolean, positions: Number[]}}
   */
  getRandomTemplate() {
    let names = Object.keys(this.layoutTemplates);
    let name = names[Math.floor(this.rng.next() * names.length)];
    let flipped = this.rng.next() > 0.5;
    let tpl = this.getTemplate(name, flipped);

    // 60 (center) must be included to place gold
    if (!tpl.positions.includes(60)) {
      throw Error(`Template "${name}", flip=${+flipped}, lacks 60.`);
    }

    return tpl;
  }

  /**
   * Flip a template array.
   *
   * The array is modified in place!
   *
   * @param {Number[]} tpl
   * @returns {Number[]}
   */
  flipTemplate(tpl) {
    return tpl
      .sort((a, b) => a - b)
      .map((n) => {
        let {x, y} = gridIndexToXy(n);
        return xyToGridIndex(5 + y - x, y);
      })
      .sort((a, b) => a - b);
  }

  /**
   * Print array of all occupied orbs as a layout template
   *
   * @returns {Number[]}
   */
  toTemplate() {
    return Object.keys(game.board.grid)
      .map((x) => +x)
      .sort((a, b) => a - b);
  }

  /**
   * Run a template editor
   *
   * - click on tiles to toggle orbs
   * - call `game.showTemplate('wheel')` to show an existing template on the board
   * - call `JSON.stringify(game.toTemplate())` to print the current template array to console
   */
  templateBuilder() {
    this.board.removeAllOrbs();

    this.board.onTileClick = (n) => {
      let symbol = 'lead';
      this.board.placeOrbByIndex(n, symbol);
    };

    this.board.onOrbClick = (n, orb) => {
      this.board.removeOrbByIndex(n)
    };
  }

  placeOrbs(template) {
    let placer = new RecursiveOrbPlacer(this, template);
    return placer.place();
  }

  getPairSymbols(first) {
    return {
      'salt':    ['salt', 'air', 'fire', 'water', 'earth'],
      'air':     ['salt', 'air'],
      'fire':    ['salt', 'fire'],
      'water':   ['salt', 'water'],
      'earth':   ['salt', 'earth'],
      'mercury': [this.nextMetal],
      'lead':    ['mercury'],
      'tin':     ['mercury'],
      'iron':    ['mercury'],
      'copper':  ['mercury'],
      'silver':  ['mercury'],
      'gold':    [],
      'mors':    ['vitae'],
      'vitae':   ['mors'],
    }[first];
  }

  advanceMetal() {
    if (this.nextMetal === 'gold') throw new Error("No metals to unlock beyond gold.");
    this.nextMetal = METAL_SEQ[METAL_SEQ.indexOf(this.nextMetal) + 1];
    console.debug(`Next metal unlocked: ${this.nextMetal}`);
  }

  /**
   * Convert the strings in the board array to actual SVG orbs (strings are a placeholder to speed up board solving)
   */
  renderPreparedBoard() {
    for (let n = 0; n < BOARD_SIZE; n++) {
      if (this.board.grid[n] !== null) {
        const symbol = this.board.grid[n];
        this.board.grid[n] = null;
        this.board.placeOrbByIndex(n, symbol);
      }
    }
  }

  /**
   * Check if a tile is available at play-time (checking unlocked metals)
   *
   * @param n
   * @return {Boolean}
   */
  isAvailableAtPlaytime(n) {
    let ava = this.board.isAvailable(n);

    const sym = this.board.grid[n].symbol;
    if (METAL_SEQ.includes(sym)) {
      if (sym !== this.nextMetal) {
        ava = false;
      }
    }

    return ava;
  }

  addUndoRecord(orbs) {
    this.undoStack.push({
      nextMetal: this.nextMetal,
      orbs,
    })
  }

  undo() {
    if (!this.undoStack.length) {
      console.warn("Undo stack is empty.");
      return;
    }

    let item = this.undoStack.pop();

    this.nextMetal = item.nextMetal;
    for (let entry of item.orbs) {
      this.debug(`Undo orb ${entry.symbol} at ${entry.n}`);
      this.board.placeOrbByIndex(entry.n, entry.symbol);
    }

    this.updateGameGui();
  }

  /**
   * Handle orb click
   *
   * @param n
   * @param orb
   */
  inGameBoardClick(n, orb) {
    let removed = false;
    this.debug(`Clicked orb ${n}: ${orb.symbol}`);

    if (!this.isAvailableAtPlaytime(n)) {
      this.debug(`Orb is blocked`);
      return;
    }

    let wantRefresh = false;

    if (orb.symbol === 'gold') {
      // gold has no pairing
      this.debug(`Removing gold.`);

      this.addUndoRecord([{
        symbol: orb.symbol,
        n,
      }]);

      this.board.removeOrbByIndex(n);
      this.selectedOrb = null;
      removed = true;
      wantRefresh = true;
    } else if (this.selectedOrb === null) {
      this.debug(`Select orb`);
      // first selection
      this.selectedOrb = {n, orb};
      orb.node.classList.add('selected');
    } else {
      if (this.selectedOrb.n === n) {
        this.debug(`Unselect orb`);
        // orb clicked twice
        orb.node.classList.remove('selected');
        this.selectedOrb = null;
      } else {
        this.debug(`Second selection, try to match`);

        // second orb in a pair
        const otherSymbol = this.selectedOrb.orb.symbol;

        if (this.getPairSymbols(orb.symbol).includes(otherSymbol)) {
          this.debug(`Match confirmed, removing ${this.selectedOrb.n} (${this.selectedOrb.orb.symbol} + ${n} (${orb.symbol}`);

          this.addUndoRecord([
            {
              symbol: this.selectedOrb.orb.symbol,
              n: this.selectedOrb.n,
            },
            {
              symbol: orb.symbol,
              n,
            }
          ]);

          // compatible pair clicked
          this.board.removeOrbByIndex(n);
          this.board.removeOrbByIndex(this.selectedOrb.n);

          removed = true;

          if ([orb.symbol, otherSymbol].includes(this.nextMetal)) {
            this.debug("Advance metal transmutation sequence.");
            this.advanceMetal();
          }

          this.selectedOrb = null;

          wantRefresh = true;
        } else {
          this.debug("No match, start new pair selection.");

          // Bad selection, select it as the first orb.
          this.selectedOrb.orb.node.classList.remove('selected');
          this.selectedOrb = {n, orb};
          orb.node.classList.add('selected');
        }
      }
    }

    if (wantRefresh) {
      if (this.board.countOrbs() === 0) {
        this.info("Good work!");

        if (removed) {
          setTimeout(() => {
            this.board.youWin.classList.add('show');
          }, 500);
        }
      }

      this.updateGameGui();
    }
  }

  /**
   * Add event handlers for the menu buttons
   */
  installButtonHandlers() {
    this.board.buttons.restart.addEventListener('click', () => {
      this.info("New Game with the same seed");
      while (this.undoStack.length) {
        this.undo();
      }
    });

    this.board.buttons.randomize.addEventListener('click', () => {
      this.info("New Game with a random seed");
      this.newGameWithLoader(+new Date);
    });

    this.board.buttons.btnAbout.addEventListener('click', () => {
      let url = 'https://git.ondrovo.com/MightyPork/sigmar';
      this.info(`Opening docs page in new tab: ${url}`);
      window.open(url);
    });

    this.board.buttons.undo.addEventListener('click', () => {
      if (this.undoStack.length) {
        this.undo();
      } else {
        this.warn("Nothing to undo.");
      }
    });

    this.board.buttons.optFancy.addEventListener('click', () => {
      this.info("Toggle effects");
      this.setCfg({
        svgEffects: !this.cfg.svgEffects,
      })
    });

    this.board.buttons.optBlockedEffect.addEventListener('click', () => {
      this.info("Toggle blocked dim");
      this.setCfg({
        dimBlocked: !this.cfg.dimBlocked,
      })
    });

    this.board.buttons.toggleFullscreen.addEventListener('click', () => {
      this.info("Toggle Fullscreen");
      if (document.fullscreenElement) {
        document.exitFullscreen();
      } else {
        this.board.$svg.requestFullscreen();
      }
    });
  }

  /**
   * Update button hiding attributes, disabled orb effects, etc
   */
  updateGameGui() {
    let nOrbs = this.board.countOrbs();

    this.board.buttons.restart
      .classList.toggle('disabled', nOrbs === 55);

    this.board.buttons.undo
      .classList.toggle('disabled', this.undoStack.length === 0);

    // Update orb disabled status
    for (let n = 0; n < BOARD_SIZE; n++) {
      if (this.board.grid[n]) {
        const disabled = !this.isAvailableAtPlaytime(n);
        let node = this.board.grid[n].node;
        if (node.classList.contains('disabled') !== disabled) {
          node.classList.toggle('disabled', disabled);
        }
      }
    }

    if (nOrbs !== 0) {
      this.board.youWin.classList.remove('show');
    }
  }

  newGameWithLoader(seed) {
    this.board.buttons.working.classList.add('show');

    setTimeout(() => {
      this.newGame(seed);
      this.board.buttons.working.classList.remove('show');
    }, 20);
  }

  newGame(seed) {
    if (seed !== null) {
      this.rng.setSeed(seed);
    }

    this.info("RNG seed is: " + this.rng.seed);

    if (this.get_opts.url_seed) {
      // Place seed in the navbar for bookmarking / sharing
      Nav.setUrlArgs({
        seed: this.rng.seed,
      });
    }

    this.board.onTileClick = (n) => {
      this.debug(n, gridIndexToXy(n));
    };

    this.selectedOrb = null;
    this.nextMetal = 'lead';
    this.undoStack = [];

    let self = this;
    this.board.onOrbClick = (n, orb) => self.inGameBoardClick(n, orb);

    // retry loop, should not be needed if everything is correct
    let suc = false;
    let retry_count = 0;
    let board_info = null;
    for (let n_tpl = 0; n_tpl < this.cfg.attemptTemplates && !suc; n_tpl++) {
      this.debug('RNG seed is: ' + this.rng.state);

      let template;
      if (n_tpl === 0 && this.get_opts.template !== null) {
        template = this.getTemplate(this.get_opts.template, this.get_opts.template_flip);
      } else {
        template = this.getRandomTemplate();
      }

      this.info(`Selected board layout template "${template.name}"`);

      for (let n_solution = 0; n_solution < this.cfg.retryTemplate; n_solution++) {
        try {
          board_info = this.placeOrbs(template.positions.slice(0)); // clone
          board_info.template = template;
          suc = true;
          break;
        } catch (e) {
          retry_count++;
          this.warn(e.message);
        }
      }

      if (!suc) {
        this.warn(`Exhausted all retries for the template "${template.name}", getting a new one`);
      }
    }

    this.renderPreparedBoard();
    this.updateGameGui();

    if (!suc) {
      alert(`Sorry, could not find a valid board setup after ${retry_count} retries.`);
    } else {
      this.info(`Board set up with ${retry_count} retries.`);

      if (this.cfg.logSolution) {
        this.info('Reference solution:\n  ' + board_info.solution.reduce((s, entry, i) => {
          s += `${entry[0]} ${entry[1]}`;

          if (i % 2 === 1) {
            s += "\n  ";
          } else {
            if (entry[0] !== 'gold') {
              s += " + ";
            }
          }

          return s;
        }, ''));
      }
    }
  }
}

/**
 * Base class for orb placer.
 */
class BaseOrbPlacer {
  constructor(game, template) {
    this.template = template;
    this.rng = game.rng;
    this.cfg = game.cfg;
    this.game = game;
    this.board = game.board;
  }

  /**
   * Place an orb. The position must be inside template and free.
   *
   * @param n - position
   * @param symbol - symbol to place
   */
  placeOrb(n, symbol) {
    if (!this.templateMap[n]) {
      throw Error(`Position ${n} not allowed by template`);
    }
    if (this.board.grid[n]) {
      throw Error(`Position ${n} is occupied by ${this.board.grid[n]}`);
    }
    this.trace(`Place ${n} <- ${symbol}`);
    this.board.grid[n] = symbol;
  }

  /**
   * Remove an orb, if any.
   *
   * @param n - position
   */
  removeOrb(n) {
    let old = this.board.grid[n];
    this.board.grid[n] = null;
    this.trace(`Unplace ${n} (${old})`);
    return old;
  }

  /** Check if a cell is available for selection - 3 free slots */
  isAvailable(n) {
    return this.board.isAvailable(n);
  }

  /** Get cell info by number */
  getCellInfo(n) {
    return this.board.getCellInfo(n);
  }

  trace(...args) {
    this.game.debug(...args);
  }

  debug(...args) {
    this.game.debug(...args);
  }

  info(...args) {
    this.game.info(...args);
  }

  warn(...args) {
    this.game.warn(...args);
  }

  error(...args) {
    this.game.error(...args);
  }

  /**
   * Build a list of orbs in the right order.
   * They are always grouped in matching pairs, and metals are always in
   * the correct reaction order.
   *
   * @return {string[]}
   */
  buildPlacementList() {
    let toPlace = [
      ['air', 'air'],
      ['air', 'air'],
      ['air', 'air'],
      ['air', 'air'],

      ['fire', 'fire'],
      ['fire', 'fire'],
      ['fire', 'fire'],
      ['fire', 'fire'],

      ['water', 'water'],
      ['water', 'water'],
      ['water', 'water'],
      ['water', 'water'],

      ['earth', 'earth'],
      ['earth', 'earth'],
      ['earth', 'earth'],
      ['earth', 'earth'],
    ];

    let newSaltedPairs = [];
    const nsalted = this.rng.nextInt(2);
    for (let i = 0; i < nsalted; i++) {
      while (true) {
        const n = this.rng.nextInt(toPlace.length - 1);
        if (toPlace[n][1] !== 'salt') {
          this.trace(`Pairing ${toPlace[n][1]} with salt.`);
          newSaltedPairs.push([toPlace[n][1], 'salt']);
          toPlace[n][1] = 'salt';
          break;
        }
      }
    }
    toPlace = toPlace.concat(newSaltedPairs);
    // if we have some salt pairs left
    for (let i = 0; i < 2 - nsalted; i++) {
      toPlace.push(['salt', 'salt']);
    }

    // shuffle the pairs that have random order (i.e. not metals)
    this.rng.arrayShuffle(toPlace);

    this.rng.arraySneak(toPlace, 3, 0, [
      ['mors', 'vitae'],
      ['mors', 'vitae'],
      ['mors', 'vitae'],
      ['mors', 'vitae'],
    ]);

    this.rng.arraySneak(toPlace, 4, 0, [
      ['lead', 'mercury'],
      ['tin', 'mercury'],
      ['iron', 'mercury'],
      ['copper', 'mercury'],
      ['silver', 'mercury'],
    ]);

    this.debug('Placement order (last first):', toPlace);

    return toPlace.reduce((a, c) => {
      a.push(c[0]);
      a.push(c[1]);
      return a;
    }, []);
  }

  /**
   * Run the placement logic.
   */
  place() {
    this.board.removeAllOrbs();
    this.solution = [];

    let allowedTable = [];
    let outsideTemplate = [];
    for (let i = 0; i < BOARD_SIZE; i++) {
      const allo = this.template.includes(i);
      allowedTable.push(allo);

      let {x, y} = gridIndexToXy(i);
      if (!allo && !isXyOutside(x, y)) {
        outsideTemplate.push(i);
      }

      // Highlight pattern shape

      if (this.cfg.highlightTemplate) {
        if (this.board.tiles[i]) {
          if (allo) {
            this.board.tiles[i].setAttribute('opacity', 1)
          } else {
            this.board.tiles[i].setAttribute('opacity', 0.6)
          }
        }
      }
    }
    this.templateMap = allowedTable;
    this.outsideTemplate = outsideTemplate;

    this.placeOrb(60, 'gold');
    this.solution.push(['gold', 60]);

    let toPlace = this.buildPlacementList();
    let rv = this.doPlace(toPlace);

    let solution = this.solution;

    solution.reverse();
    this.info("Found a valid board!");

    solution.forEach((a) => {
      let p = gridIndexToXy(a[1]);
      a[1] = `${p.x}Ă—${p.y}`;
    });

    this.debug('Solution:', solution);

    rv.solution = solution;
    return rv;
  }

  /**
   * Perform the orbs placement.
   *
   * Orb symbols to place are given as an argument.
   *
   * The layout template is in `this.template`, also `this.templateMap` as an array
   * with indices 0-121 containing true/false to indicate template membership.
   *
   * `this.outsideTemplate` is a list of cell indices that are not in the template.
   *
   * this.solution is an array to populate with orb placements in the format [n, symbol].
   *
   * The board is cleared now. When the function ends, the board should contain
   * the new layout (as symbol name strings)
   *
   * After placing each orb, make sure to call `this.solution.push([symbol, index])`;
   * in case of backtracking, pop it again.
   *
   * Return object to return to parent; 'solution' will be added automatically.
   */
  doPlace(toPlace) {
    throw new Error("Not implemented");
  }
}

class RecursiveOrbPlacer extends BaseOrbPlacer {
  doPlace(toPlace) {
    this.toPlace = toPlace;
    this.recDepth = 0;

    this.placeOne(0);

    return {};
  }

  placeOne() {
    this.recDepth++;
    if (this.recDepth > 1000) {
      throw new Error("Too many backtracks");
    }

    let symbol = this.toPlace.pop();

    let candidates = [[],[],[],[],[],[],[]];
    for (let n of this.template) {
      if (!this.board.grid[n]) {
        // is free
        const cell = this.getCellInfo(n);
        if (cell.freeSequence >= 3) {
          candidates[cell.neighbours].push(n);
        }
      }
    }

    for (let neighs = 6; neighs >= 0; neighs--) {
      this.rng.arrayShuffle(candidates[neighs]);
    }

    for (let i = 6; i >= 0; i--) {
      if (candidates[i].length) {
        for (let n of candidates[i]) {
          this.placeOrb(n, symbol);

          // avoid deadlocking
          if (this.solution.length % 2 === 0) {
            // this is the second orb in a pair (solution starts with gold)
            let prevn = this.solution[this.solution.length-1][1];
            if (!this.isAvailable(prevn)) {
              this.trace("Avoid deadlock (this is the second of a pair)");
              this.removeOrb(n);
              continue;
            }
          }

          this.solution.push([symbol, n]);

          if (this.toPlace.length) {
            if (this.placeOne()) {
              return true;
            } else {
              this.trace("Undo and continue");
              this.removeOrb(n);
              this.solution.pop();
              // and continue the iteration
            }
          } else {
            return true;
          }
        }
      }
    }

    // give it back
    this.toPlace.push(symbol);
    return false;
  }
}

/* Start */

window.game = new Game();