Code viewer for Mind: Trapping Agent
/**
 * A Star with Herding implementation on grid
 * himanshu.warekar2@mail.dcu.ie
 */

/**
 * Node for initializing at every GRID position which contains
 * the information for A Star.
 */
 class Node {
  constructor(world, idx, jdx) {
    this.world = world;
    this.GRID = world.GRID;
    this.idx = idx;
    this.jdx = jdx;

    this.columns = world.gridSize;
    this.rows = world.gridSize;
    this.squareSize = world.squareSize;

    this.calculatedTotal = 0; // f
    this.stepCost = 0; // g
    this.heuristicEstimate = 0; // h

    this.neighbours = [];
    this.previous = null;

    this.gridWall = false;
    this.mazeWall = false;

    this.isEmpty = false;

    this.tempCube = null;
    this.Cube = null;
  }

  addNeighbours() {
    if (this.idx < this.columns - 1) {
      this.neighbours.push(this.GRID[this.idx + 1][this.jdx]);
    }
    if (this.idx > 0) {
      this.neighbours.push(this.GRID[this.idx - 1][this.jdx]);
    }
    if (this.jdx < this.rows - 1) {
      this.neighbours.push(this.GRID[this.idx][this.jdx + 1]);
    }
    if (this.jdx > 0) {
      this.neighbours.push(this.GRID[this.idx][this.jdx - 1]);
    }
  }

  setAsGridWall() {
    this.gridWall = true;
  }

  setAsMazeWall() {
    this.mazeWall = true;
  }

  setAsEmpty() {
    this.isEmpty = true;
  }

  isWall() {
    return this.gridWall || this.mazeWall;
  }

  showTemperoryPath() {
    this.Shape = new THREE.BoxGeometry(30, 30, 30);
    this.Cube = new THREE.Mesh(this.Shape);

    this.Cube.material = new THREE.MeshBasicMaterial();

    this.Cube.position.copy(this.translate(this.idx, this.jdx));
    this.Cube.material.color.setColorName("blue");
    this.Cube.material.opacity = 0.25;
    ABWorld.scene.add(this.Cube);
  }

  removeTemperoryPath() {
    ABWorld.scene.remove(this.Cube);
    this.Shape = null;
    this.Cube = null;
  }

  showPermanentPath() {
    this.Shape = new THREE.BoxGeometry(30, 30, 30);
    this.Cube = new THREE.Mesh(this.Shape);

    this.Cube.material = new THREE.MeshBasicMaterial();

    this.Cube.position.copy(this.translate(this.idx, this.jdx));
    this.Cube.material.color.setColorName("green");
    this.Cube.material.opacity = 0.25;
    ABWorld.scene.add(this.Cube);
  }

  removePermanentPath() {
    ABWorld.scene.remove(this.Cube);
    this.Shape = null;
    this.Cube = null;
  }

  showCornerIndicator() {
    this.Shape = new THREE.BoxGeometry(30, 30, 30);
    this.Cube = new THREE.Mesh(this.Shape);

    this.Cube.material = new THREE.MeshBasicMaterial();

    this.Cube.position.copy(this.translate(this.idx, this.jdx));
    this.Cube.material.color.setColorName("orange");
    this.Cube.material.opacity = 0.25;
    ABWorld.scene.add(this.Cube);
  }

  removeCornerIndicator() {
    ABWorld.scene.remove(this.Cube);
    this.Shape = null;
    this.Cube = null;
  }

  translate(idx, jdx) {
    let vector = new THREE.Vector3();

    vector.y = 0;
    vector.x = idx * this.world.squareSize - this.world.MAXPOS / 2;
    vector.z = jdx * this.world.squareSize - this.world.MAXPOS / 2;

    return vector;
  }
}

/**
 * Analytics to save the totalSteps, goodSteps, badSteps, score, agentBlocked
 * for analytics.
 */
class Analytics {
  constructor() {
    this.API_URL = "https://api.airtable.com/v0/appaNU1MlDvrEYsnk/Table%201";
    this.API_KEY = "a2V5T1BOeXRjUElpV3pCU0c=";
  }

  sendAnalytics(world, totalSteps, goodSteps, badSteps, score, agentBlocked) {
    let data = {
      "records": [
        {
          "fields": {
            "world": world,
            "totalSteps": totalSteps,
            "goodSteps": goodSteps,
            "badSteps": badSteps,
            "score": score,
            "agentBlocked": agentBlocked
          }
        }
      ]
    };

    $.ajax({
      type: "POST",
      url: this.API_URL,
      contentType: "application/json",
      data: JSON.stringify(data),
      headers: {
        "Authorization": "Bearer " + atob(this.API_KEY)
      },
      success: function() {}
    });
  }
}

/**
 * Initialises the Complex world for Herding the Agent into the nearest corner
 * using A Star acting like a TSP algorithm.
 */
class complexWorld {
  constructor() {
    // ===================================================================================================================
    // === Start of tweaker's box ========================================================================================
    // ===================================================================================================================

    // The easiest things to modify are in this box.
    // You should be able to change things in this box without being a JavaScript programmer.
    // Go ahead and change some of these. What's the worst that could happen?
    AB.clockTick = 100;

    // Speed of run: Step every n milliseconds. Default 100.
    AB.maxSteps = 1000;

    // Length of run: Maximum length of run in steps. Default 1000.
    AB.screenshotStep = 50;

    this.WORLD = "Trapping Agent World"

    // Switch between 3d and 2d view (both using Three.js)
    this.show3d = true;

    this.TEXTURE_WALL = "/uploads/starter/door.jpg";
    this.TEXTURE_MAZE = "/uploads/starter/latin.jpg";
    this.TEXTURE_AGENT = "/uploads/starter/pacman.jpg";
    this.TEXTURE_ENEMY = "/uploads/starter/ghost.3.png";

    this.MUSIC_BACK = "/uploads/starter/Defense.Line.mp3";
    this.SOUND_ALARM = "/uploads/starter/air.horn.mp3";

    // number of squares along side of world
    this.gridSize = 50;

    this.NOBOXES = Math.trunc((this.gridSize * this.gridSize) / 3);
    // density of maze - number of internal boxes
    // (bug) use trunc or can get a non-integer

    this.squareSize = 100; // size of square in pixels
    this.MAXPOS = this.gridSize * this.squareSize; // length of one side in pixels
    this.SKYCOLOR = 0xddffdd; // a number, not a string

    this.startRadiusConst = this.MAXPOS * 0.8; // distance from centre to start the camera at
    this.maxRadiusConst = this.MAXPOS * 10; // maximum distance from camera we will render things

    //--- change ABWorld defaults: -------------------------------
    ABHandler.MAXCAMERAPOS = this.maxRadiusConst;
    ABHandler.GROUNDZERO = true; // "ground" exists at altitude zero

    this.SKYBOX_ARRAY = [
      "/uploads/hrwx/black.jpg",
      "/uploads/hrwx/black.jpg",
      "/uploads/hrwx/black.jpg",
      "/uploads/hrwx/black.jpg",
      "/uploads/hrwx/black.jpg",
      "/uploads/hrwx/black.jpg",
    ];

    this.ACTION_LEFT = 0;
    this.ACTION_RIGHT = 1;
    this.ACTION_UP = 2;
    this.ACTION_DOWN = 3;
    this.ACTION_STAYSTILL = 4;

    this.GRID_BLANK = 0;
    this.GRID_WALL = 1;
    this.GRID_MAZE = 2;

    this.BOXHEIGHT; // 3d or 2d box height

    this.GRID = new Array(this.gridSize); // can query GRID about whether squares are occupied, will in fact be initialised as a 2D array

    this.theAgent = null;
    this.theEnemy = null;

    this.wall_texture = null;
    this.agent_texture = null;
    this.enemy_texture = null;
    this.maze_texture = null;

    // enemy and agent position on squares
    this.enemyIdx = null;
    this.enemyJdx = null;
    this.agentIdx = null;
    this.agentJdx = null;

    this.badSteps = null;
    this.goodSteps = null;
    this._goodSteps = null; //Used to store good steps information for analytics incase agent is trapped.

    this.OURKEYS = [37, 38, 39, 40];
    this.initialized = false;

    this.path = [];
    this.trapLocations = [];
    this.stopCounter = 0;

    // ===================================================================================================================
    // === End of tweaker's box ==========================================================================================
    // ===================================================================================================================

    this.loadResources();
    this.initMusic();
    this.initAnalytics();
  }

  loadResources() {
    this.initWallTexture();
    this.initAgentTexture();
    this.initEnemyTexture();
    this.initMazeTexture();
  }

  initAnalytics() {
    this.analytics = new Analytics();
  }

  initWallTexture() {
    let me = this;
    let wall_texture_loader = new THREE.TextureLoader();

    wall_texture_loader.load(this.TEXTURE_WALL, function (thetexture) {
      thetexture.minFilter = THREE.LinearFilter;
      me.wall_texture = thetexture;

      if (me.resourcesLoaded()) me.initScene();
    });
  }

  initAgentTexture() {
    let me = this;
    let agent_texture_loader = new THREE.TextureLoader();

    agent_texture_loader.load(this.TEXTURE_AGENT, function (thetexture) {
      thetexture.minFilter = THREE.LinearFilter;
      me.agent_texture = thetexture;

      if (me.resourcesLoaded()) me.initScene();
    });
  }

  initEnemyTexture() {
    let me = this;
    let enemy_texture_loader = new THREE.TextureLoader();

    enemy_texture_loader.load(this.TEXTURE_ENEMY, function (thetexture) {
      thetexture.minFilter = THREE.LinearFilter;
      me.enemy_texture = thetexture;

      if (me.resourcesLoaded()) me.initScene();
    });
  }

  initMazeTexture() {
    let me = this;
    let maze_texture_loader = new THREE.TextureLoader();

    maze_texture_loader.load(this.TEXTURE_MAZE, function (thetexture) {
      thetexture.minFilter = THREE.LinearFilter;
      me.maze_texture = thetexture;

      if (me.resourcesLoaded()) me.initScene();
    });
  }

  initMusic() {
    this.music = AB.backgroundMusic(this.MUSIC_BACK);
  }

  playMusic() {
    this.music.play();
  }

  soundAlarm() {
    let alarm = new Audio(this.SOUND_ALARM);
    alarm.play();
  }

  pauseMusic() {
    this.music.pause();
  }

  resourcesLoaded() {
    return (
      this.wall_texture &&
      this.agent_texture &&
      this.enemy_texture &&
      this.maze_texture
    );
  }

  newRun() {
    AB.loadingScreen();
    AB.runReady = false;

    this.badSteps = 0;
    this.goodSteps = 0;

    if (this.show3d) {
      this.BOXHEIGHT = this.squareSize;
      ABWorld.init3d(this.startRadiusConst, this.maxRadiusConst, this.SKYCOLOR);
    } else {
      this.BOXHEIGHT = 1;
      ABWorld.init2d(this.startRadiusConst, this.maxRadiusConst, this.SKYCOLOR);
    }

    this.loadResources();

    document.onkeydown = AB.world.customKeyHandler;
  }

  endRun() {
    this.pauseMusic();

    if (AB.abortRun) {
      AB.msg(
        " <br> <font color=red> <B> Agent trapped. Final score zero. </B> </font>",
        3
      );
    } else {
      AB.msg(" <br> <font color=green> <B> Run over. </B> </font>", 3);
    }
    let score = (this._goodSteps / (AB.step - 1)) * 100;
    this.analytics.sendAnalytics(this.WORLD, AB.step - 1, this._goodSteps, this.badSteps, parseFloat(score.toFixed(2)), this.isAgentBlocked());
  }

  isOccupied(idx, jdx) {
    // variable objects
    if (this.enemyIdx === idx && this.enemyJdx === jdx) return true;
    if (this.agentIdx === idx && this.agentJdx === jdx) return true;
    if (this.GRID[idx][jdx].isWall()) return true;

    return false;
  }

  isBadStep(locationIdx, locationJdx) {
    // is the enemy within one square of the agent
    locationIdx = locationIdx || this.enemyIdx;
    locationJdx = locationJdx || this.enemyJdx;

    return Math.abs(locationIdx - this.agentIdx) < 2 &&
      Math.abs(locationJdx - this.agentJdx) < 2
      ? true
      : false;
  }

  isAgentBlocked() {
    // agent is blocked on all sides, run over
    return (
      this.isOccupied(this.agentIdx - 1, this.agentJdx) &&
      this.isOccupied(this.agentIdx + 1, this.agentJdx) &&
      this.isOccupied(this.agentIdx, this.agentJdx - 1) &&
      this.isOccupied(this.agentIdx, this.agentJdx + 1)
    );
  }

  isAgentKindaBlocked() {
    // agent is blocked on all sides, run over
    let blocks = [
      this.isOccupied(this.agentIdx - 1, this.agentJdx),
      this.isOccupied(this.agentIdx + 1, this.agentJdx),
      this.isOccupied(this.agentIdx, this.agentJdx - 1),
      this.isOccupied(this.agentIdx, this.agentJdx + 1)
    ].filter(el => el === true);

    return blocks.length === 3 ? true : false;
  }

  ourKeys(event) {
    return this.OURKEYS.includes(event.keyCode);
  }

  initScene() {
    if (this.initialized) return;

    this.setupWalls();
    this.setupMaze();
    this.setupNeighbours();
    this.setupTrapLocations();
    this.setupEnemy();
    this.setupAgent();

    this.initialized = true;
  }

  setupWalls() {
    let shape = null;
    let theCube = null;

    for (let iCounter = 0; iCounter < this.gridSize; iCounter++) {
      this.GRID[iCounter] = new Array(this.gridSize);
      for (let jCounter = 0; jCounter < this.gridSize; jCounter++) {
        if (
          !iCounter ||
          iCounter === this.gridSize - 1 ||
          !jCounter ||
          jCounter === this.gridSize - 1
        ) {
          let node = new Node(this, iCounter, jCounter);
          node.setAsGridWall();
          this.GRID[iCounter][jCounter] = node;
          shape = new THREE.BoxGeometry(
            this.squareSize,
            this.BOXHEIGHT,
            this.squareSize
          );

          theCube = new THREE.Mesh(shape);
          theCube.material = new THREE.MeshBasicMaterial({
            map: this.wall_texture,
          });

          theCube.position.copy(this.translate(iCounter, jCounter)); // translate my (i,j) grid coordinates to three.js (x,y,z) coordinates
          ABWorld.scene.add(theCube);
        } else {
          let node = new Node(this, iCounter, jCounter);
          node.setAsEmpty();
          this.GRID[iCounter][jCounter] = node;
        }
      }
    }
  }

  setupMaze() {
    let idx = null;
    let jdx = null;
    let shape = null;
    let theCube = null;

    for (let count = 1; count <= this.NOBOXES; count++) {
      idx = AB.randomIntAtoB(1, this.gridSize - 2); // inner squares are 1 to gridSize-2
      jdx = AB.randomIntAtoB(1, this.gridSize - 2);

      let node = new Node(this, idx, jdx);
      node.setAsMazeWall();
      this.GRID[idx][jdx] = node;

      shape = new THREE.BoxGeometry(
        this.squareSize,
        this.BOXHEIGHT,
        this.squareSize
      );
      theCube = new THREE.Mesh(shape);
      theCube.material = new THREE.MeshBasicMaterial({
        map: this.maze_texture,
      });

      theCube.position.copy(this.translate(idx, jdx)); // translate my (i,j) grid coordinates to three.js (x,y,z) coordinates
      ABWorld.scene.add(theCube);
    }
  }

  setupNeighbours() {
    for (let iCounter = 0; iCounter < this.gridSize; iCounter++) {
      for (let jCounter = 0; jCounter < this.gridSize; jCounter++) {
        this.GRID[iCounter][jCounter].addNeighbours(this.GRID);
      }
    }
  }

  setupEnemy() {
    // set up enemy
    // start in random location
    // search for empty square
    let idx = null;
    let jdx = null;
    let shape = null;

    do {
      idx = AB.randomIntAtoB(1, this.gridSize - 2);
      jdx = AB.randomIntAtoB(1, this.gridSize - 2);
    } while (this.isOccupied(idx, jdx));

    this.enemyIdx = idx;
    this.enemyJdx = jdx;

    shape = new THREE.BoxGeometry(
      this.squareSize,
      this.BOXHEIGHT,
      this.squareSize
    );
    this.theEnemy = new THREE.Mesh(shape);
    this.theEnemy.material = new THREE.MeshBasicMaterial({
      map: this.enemy_texture,
    });
    ABWorld.scene.add(this.theEnemy);
    this.drawEnemy();
  }

  setupAgent() {
    // set up agent
    // start in random location
    // search for empty square
    let idx = null;
    let jdx = null;
    let shape = null;

    do {
      idx = AB.randomIntAtoB(1, this.gridSize - 2);
      jdx = AB.randomIntAtoB(1, this.gridSize - 2);
    } while (this.isOccupied(idx, jdx)); // search for empty square

    this.agentIdx = idx;
    this.agentJdx = jdx;

    shape = new THREE.BoxGeometry(
      this.squareSize,
      this.BOXHEIGHT,
      this.squareSize
    );
    this.theAgent = new THREE.Mesh(shape);
    this.theAgent.material = new THREE.MeshBasicMaterial({
      map: this.agent_texture,
    });
    ABWorld.scene.add(this.theAgent);
    this.drawAgent();

    // finally skybox
    // setting up skybox is simple
    // just pass it array of 6 URLs and it does the asych load

    ABWorld.scene.background = new THREE.CubeTextureLoader().load(
      this.SKYBOX_ARRAY,
      function () {
        ABWorld.render();
        AB.removeLoading();
        AB.runReady = true; // start the run loop
      }
    );
  }

  setupTrapLocations() {
    let idx = null;
    let jdx = null;

    do {
      idx = AB.randomIntAtoB(1, this.gridSize - 2);
      jdx = AB.randomIntAtoB(1, this.gridSize - 2);
    } while (this.isOccupied(idx, jdx));

    for (let iCounter = 0; iCounter < this.gridSize; iCounter++) {
      for (let jCounter = 0; jCounter < this.gridSize; jCounter++) {
        let node = this.GRID[iCounter][jCounter];
        if (node.isWall()) {
          continue;
        }

        let canBeTrap = node.neighbours.filter(_node => !_node.isWall());
        if (canBeTrap.length === 1) {
          this.resetNodeParameters();
          this.getPath({
            startNode: this.GRID[idx][jdx],
            endNode: this.GRID[iCounter][jCounter],
            fn: () => {
              this.trapLocations.push(this.GRID[iCounter][jCounter]);
            }
          });
        }
      }
    }
  }

  translate(idx, jdx) {
    let vector = new THREE.Vector3();

    vector.y = 0;
    vector.x = idx * this.squareSize - this.MAXPOS / 2;
    vector.z = jdx * this.squareSize - this.MAXPOS / 2;

    return vector;
  }

  drawEnemy() {
    // translate my (i,j) grid coordinates to three.js (x,y,z) coordinates
    // if camera moving, look back at where the enemy is
    this.theEnemy.position.copy(this.translate(this.enemyIdx, this.enemyJdx));
    ABWorld.lookat.copy(this.theEnemy.position);
  }

  drawAgent() {
    // translate my (i,j) grid coordinates to three.js (x,y,z) coordinates
    // if camera moving, look back at where the enemy is
    this.theAgent.position.copy(this.translate(this.agentIdx, this.agentJdx));
    ABWorld.follow.copy(this.theAgent.position);
  }

  addPath(node) {
    this.path.push(node);
  }

  /**
   * Computes manhattan distance
   * @param {Array} nodes nodes
   * @returns {integer} manhattan distance
   */
  heuristic(nodes) {
    let startNode = null;
    let intermediateNode = null;
    let endNode = null;

    if (nodes.length === 2) {
      startNode = nodes[0];
      endNode = nodes[1];

      return (
        Math.abs(startNode.idx - endNode.idx) +
        Math.abs(startNode.jdx - endNode.jdx)
      );
    } else {
      startNode = nodes[0];
      intermediateNode = nodes[1];
      endNode = nodes[2];

      return Math.min(
        Math.abs(startNode.idx - intermediateNode.idx) + Math.abs(startNode.jdx - intermediateNode.jdx),
        Math.abs(intermediateNode.idx - endNode.idx) + Math.abs(intermediateNode.jdx - endNode.jdx),
      );
    }
  }

  resetNodeParameters() {
    for (let idx = 0; idx < this.gridSize; idx++) {
      for (let jdx = 0; jdx < this.gridSize; jdx++) {
        let node = this.GRID[idx][jdx];

        if (node && !node.isWall()) {
          node.removeTemperoryPath();
          node.calculatedTotal = null;
          node.stepCost = null;
          node.heuristicEstimate = null;
          node.previous = null;
        }
      }
    }
  }

  /**
   * Moves the enemy in the direction of the path returned by A star
   * Herds the agent in the location of trap node by maintaining a herding distance
   * of one node between the agent and enemy. This gives the agent a possibility to
   * escape if stuck.
   */
  moveLogicalEnemy() {
    // move towards agent
    // put some randomness in so it won't get stuck with barriers

    this.resetNodeParameters();
    let startNode = this.GRID[this.enemyIdx][this.enemyJdx];
    let intermediateNode = this.GRID[this.agentIdx][this.agentJdx];
    let endNode = this.getLocationForHerdingAgent();
    let currentNode = this.getPath({
      startNode: startNode,
      intermediateNode: intermediateNode,
      endNode: endNode
    });
    let tempPath = [];
    let idx = null;
    let jdx = null;

    tempPath.push(currentNode);
    while (currentNode && currentNode.previous) {
      tempPath.push(currentNode.previous);
      currentNode = currentNode.previous;

      if (!currentNode || !currentNode.previous) {
        break;
      }

      currentNode.showTemperoryPath();
    }

    if (tempPath.length < 2) {
      return;
    }

    let nextStep = tempPath[tempPath.length - 2];
    idx = nextStep.idx;
    jdx = nextStep.jdx;

    if (!this.isOccupied(idx, jdx)) {
      // if no obstacle then move, else just miss a turn
      if (!this.isBadStep(idx, jdx) || this.isAgentKindaBlocked() || this.stopCounter > 10) {
        this.enemyIdx = idx;
        this.enemyJdx = jdx;
        this.stopCounter = 0;

        this.addPath(this.GRID[this.enemyIdx][this.enemyJdx]);
      } else {
        this.stopCounter += 1;
        let node = null;
        try {
          this.path.pop();
          node = this.path[this.path.length-2];
          this.enemyIdx = node.idx;
          this.enemyJdx = node.jdx;
        } catch (e) {
          return;
        };
      }
    }
  }

  /**
   * Returns the end node after computing A Star
   * @param {JSON} args
   *    @param {object} startNode start node for A star
   *    @param {object} intermediateNode intermediate node for A star
   *    @param {object} endNode end node for A star
   *    @param {object} fn custom function to run when path is found
   *    @param {object} returnTotalCost returns only total cost
   *
   * @returns {object} returns the end node for A star
   */
  getPath(args) {
    let startNode = args.startNode;
    let intermediateNode = args.intermediateNode;
    let endNode = args.endNode;
    let fn = args.fn;
    let openNodes = [startNode];
    let closedNodes = [];
    let currentNode = null;
    let totalCost = 0;
    let returnTotalCost = args.returnTotalCost || false;
    let noPath = true;
    let _end = intermediateNode ? intermediateNode : endNode;

    while (openNodes.length) {
      let _winner = 0;
      for (let iCounter = 0; iCounter < openNodes.length; iCounter++) {
        if (
          openNodes[iCounter].calculatedTotal <
          openNodes[_winner].calculatedTotal
        ) {
          _winner = iCounter;
        }
      }
      currentNode = openNodes[_winner];

      // If intermediateNode path is reached viz the agent, assign endNode to _end to
      // further travel to trap location
      if (intermediateNode && currentNode === intermediateNode) {
        _end = endNode
      }

      // Did I finish?
      if (currentNode === _end) {
        // console.log("success - found path");
        noPath = false;
        if (fn) {
          fn();
        }

        break;
      }

      openNodes = this.removeFromArray(openNodes, currentNode);
      closedNodes.push(currentNode);

      for (
        let iCounter = 0;
        iCounter < currentNode.neighbours.length;
        iCounter++
      ) {
        let node = currentNode.neighbours[iCounter];

        if (closedNodes.includes(node) || node.isWall()) {
          continue;
        }

        let _stepCost = node.stepCost + this.heuristic([node, currentNode]);
        let newPath = false;

        if (openNodes.includes(node)) {
          if ((_stepCost < node.stepCost) || (intermediateNode && _end !== intermediateNode)) {
            node.stepCost = _stepCost;
            newPath = true;
          }
        } else {
          node.stepCost = _stepCost;
          newPath = true;
          openNodes.push(node);
        }

        if (newPath) {
          node.heuristicEstimate = this.heuristic([node, _end]);
          node.calculatedTotal = node.stepCost + node.heuristicEstimate;
          totalCost += node.stepCost + node.heuristicEstimate;
          node.previous = currentNode;
        }
      }
    }
    if (noPath) {
      return startNode;
    }

    return returnTotalCost ? totalCost : currentNode;
  }

  /**
   * Finds the nearest trap location within the square coordinates to try
   * and trap the agent
   * @returns {object} Location of the trap node
   */
  getLocationForHerdingAgent() {
    let _enemyLocation = this.GRID[this.enemyIdx][this.enemyJdx];
    let _agentLocation = this.GRID[this.agentIdx][this.agentJdx];
    let _location = {
      node: null,
      cost: 0
    };
    let _cost = null;
    let coordinates = this.getCoordinates(_agentLocation);

    if (this.trapLocations.includes(_agentLocation)) {
      _location.node = _agentLocation;
      _location.cost = 0;
    } else {
      this.trapLocations.forEach(node => {
        if (this.liesWithin(node, coordinates)) {
          _cost = this.heuristic([_enemyLocation, node, _agentLocation]);


          if (!_location.cost || _location.cost > _cost) {
            _location.node = node;
            _location.cost = _cost;
          }
        }
      });
    }

    _location.node && _location.node.showCornerIndicator();
    _location.node && setTimeout(() => {
      _location.node.removeCornerIndicator();
    }, 100);
    return _location.node || _agentLocation;
  }

  moveLogicalAgent(agent) {
    // this is called by the infrastructure that gets action a from the Mind
    let idx = this.agentIdx;
    let jdx = this.agentJdx;

    if (agent === this.ACTION_LEFT) {
      idx--;
    } else if (agent === this.ACTION_RIGHT) {
      idx++;
    } else if (agent === this.ACTION_UP) {
      jdx++;
    } else if (agent === this.ACTION_DOWN) {
      jdx--;
    }

    if (!this.isOccupied(idx, jdx)) {
      this.agentIdx = idx;
      this.agentJdx = jdx;
    }
  }

  renderPath() {
    for (let node of this.path) {
      node.showPermanentPath();
    }
  }

  getScore() {
    let steps = (this.goodSteps / AB.maxSteps) * 100;
    let score = Math.round(steps * 100);

    return score / 100;
  }

  getState() {
    return [this.agentIdx, this.agentJdx, this.enemyIdx, this.enemyJdx];
  }

  updateStatusBefore(agent) {
    // this is called before anyone has moved on this step, agent has just proposed an action
    // update status to show old state and proposed move

    let state = this.getState();
    AB.msg(" Step: " + AB.step + " &nbsp; x = (" + state.toString() + ") &nbsp; a = (" + agent + ") ");
  }

  updateStatusAfter() {
    // agent and enemy have moved, can calculate score
    // new state after both have moved
    let state = this.getState();
    let score = (this.goodSteps / AB.step) * 100;

    AB.msg(
      " &nbsp; y = (" +
        state.toString() +
        ") <br>" +
        " Bad steps: " +
        this.badSteps +
        " &nbsp; Good steps: " +
        this.goodSteps +
        " &nbsp; Score: " +
        score.toFixed(2) +
        "% ",
      2
    );
  }

  takeAction(agent) {
    this.updateStatusBefore(agent);
    this.moveLogicalAgent(agent);

    if (AB.step % 2 === 0) {
      this.moveLogicalEnemy();
    }

    this.isBadStep() ? this.badSteps++ : this.goodSteps++;

    this.drawAgent();
    this.drawEnemy();

    // show status line after moves
    this.updateStatusAfter();

    // set for analytics
    this._goodSteps = this.goodSteps;
    if (this.isAgentBlocked()) {
      // if agent blocked in, run over
      // you score zero as far as database is concerned
      AB.abortRun = true;
      this.goodSteps = 0;
      this.pauseMusic();
      this.soundAlarm();
      this.renderPath();
    }
  }

  getAction(location) {
    let agentIdx = location[0];
    let agentJdx = location[1];
    let enemyIdx = location[2];
    let enemyJdx = location[3];

    if (enemyJdx < agentJdx) {
      return AB.randomPick(
        this.ACTION_UP,
        AB.randomPick(this.ACTION_RIGHT, this.ACTION_LEFT)
      );
    } else if (enemyJdx > agentJdx) {
      return AB.randomPick(
        this.ACTION_DOWN,
        AB.randomPick(this.ACTION_RIGHT, this.ACTION_LEFT)
      );
    } else if (enemyIdx < agentIdx) {
      return AB.randomPick(
        this.ACTION_RIGHT,
        AB.randomPick(this.ACTION_UP, this.ACTION_DOWN)
      );
    } else if (enemyIdx > agentIdx) {
      return AB.randomPick(
        this.ACTION_LEFT,
        AB.randomPick(this.ACTION_UP, this.ACTION_DOWN)
      );
    } else {
      return AB.randomIntAtoB(0, 3);
    }
  }

  getKeyMap() {
    return {
      37: this.ACTION_LEFT,
      38: this.ACTION_DOWN,
      39: this.ACTION_RIGHT,
      40: this.ACTION_UP,
    };
  }

  removeFromArray(array, element) {
    let index = array.indexOf(element);
    if (index !== -1) {
      array.splice(index, 1)
    }

    return array;
  }

  /**
   * Checks if node lies within the boundaries
   * @param {object} node node to be checked if it exists in boundaries
   * @param {JSON} coordinates boundaries
   * @returns {boolean}
   */
  liesWithin(node, coordinates) {
    let topLeft = coordinates.topLeft;
    let topRight = coordinates.topRight;
    let bottomLeft = coordinates.bottomLeft;
    let bottomRight = coordinates.bottomRight;

    if (
      (node.idx < topRight.idx && node.jdx < topRight.jdx) &&
      (node.idx > topLeft.idx && node.jdx < topLeft.jdx) &&
      (node.idx > bottomLeft.idx && node.jdx > bottomLeft.jdx) &&
      (node.idx < bottomRight.idx && node.jdx > bottomRight.jdx)
    ) {
      return true;
    }

    return false;
  }

  /**
   * Computes the coordinates of square which limits the search for the trapLocation
   * @param {object} node node for which we want he boundary square
   * @returns {JSON} nodes which form the edges of the square
   */
  getCoordinates(node) {
    let distance = 5;

    // topRight
    let topRight = {
      idx: node.idx + distance,
      jdx: node.jdx + distance
    }

    if (topRight.idx > this.gridSize - 2) {
      topRight.idx = this.gridSize -2
    } else if (topRight.idx < 1) {
      topRight.idx = 1;
    }

    if (topRight.jdx > this.gridSize - 2) {
      topRight.jdx = this.gridSize -2
    } else if (topRight.jdx < 1) {
      topRight.jdx = 1;
    }

    // topLeft
    let topLeft = {
      idx: node.idx - distance,
      jdx: node.jdx + distance
    }

    if (topLeft.idx > this.gridSize - 2) {
      topLeft.idx = this.gridSize -2
    } else if (topLeft.idx < 1) {
      topLeft.idx = 1;
    }

    if (topLeft.jdx > this.gridSize - 2) {
      topLeft.jdx = this.gridSize -2
    } else if (topLeft.jdx < 1) {
      topLeft.jdx = 1;
    }

    // bottomLeft
    let bottomLeft = {
      idx: node.idx - distance,
      jdx: node.jdx - distance
    }

    if (bottomLeft.idx > this.gridSize - 2) {
      bottomLeft.idx = this.gridSize -2
    } else if (bottomLeft.idx < 1) {
      bottomLeft.idx = 1;
    }

    if (bottomLeft.jdx > this.gridSize - 2) {
      bottomLeft.jdx = this.gridSize -2
    } else if (bottomLeft.jdx < 1) {
      bottomLeft.jdx = 1;
    }

    let bottomRight = {
      idx: node.idx + distance,
      jdx: node.jdx - distance
    }

    if (bottomRight.idx > this.gridSize - 2) {
      bottomRight.idx = this.gridSize -2
    } else if (bottomRight.idx < 1) {
      bottomRight.idx = 1;
    }

    if (bottomRight.jdx > this.gridSize - 2) {
      bottomRight.jdx = this.gridSize -2
    } else if (bottomRight.jdx < 1) {
      bottomRight.jdx = 1;
    }

    return {
      topLeft: topLeft,
      topRight: topRight,
      bottomLeft: bottomLeft,
      bottomRight: bottomRight
    }
  }
}

// Initialises ComplexWorld object and attaches it to window object
window.world = new complexWorld();


/**
 * Overrides the AB.world functions to run the functions defined in Complex World
 */
AB.world = {
  newRun: function () {
    world.newRun();
  },
  endRun: function () {
    world.endRun();
  },
  getScore: function () {
    // only called at end - do not use AB.step because it may have just incremented past AB.maxSteps
    return world.getScore();
  },
  getState: function () {
    return world.getState();
  },
  takeAction: function (agent) {
    world.takeAction(agent);
  },
  customKeyHandler: function (event) {
    if (!AB.runReady) return true; // not ready yet

    // if not one of our special keys, send it to default key handling:
    if (!world.ourKeys(event)) return true;

    // else handle key and prevent default handling:
    let keyMap = world.getKeyMap();

    world.moveLogicalAgent(keyMap[event.keyCode]);

    // when the World is embedded in an iframe in a page,
    // we want arrow key events handled by World and not passed up to parent
    event.stopPropagation();
    event.preventDefault();
    return false;
  },
};

AB.mind = {
  getAction: function (location) {
    return world.getAction(location);
  },
};