src_agent_agent.js

import { moveAndWait, randomMoveAndBack } from "../actions/movement.js";
import DeliverooClient from "../api/deliverooClient.js";
import { MapStore } from "../models/mapStore.js";
import { Me } from "../models/me.js";
import { ParcelsStore } from "../models/parcelsStore.js";
import { INTENTIONS } from "../utils/intentions.js";
import { goingTowardsParcel } from "../utils/geometry.js";
import { astarSearch, direction } from "../utils/astar.js";
import { coord2Key, key2Coord } from "../utils/hashMap.js";
import { LOG_LEVELS } from "../utils/log.js";
import { log } from "../utils/log.js";
import { AgentStore } from "../models/agentStore.js";
import { TILE_TYPES } from "../utils/tile.js";
import { ServerConfig } from "../models/serverConfig.js";
import config from "../utils/gameConfig.js";
import gameConfig from "../utils/gameConfig.js";

/**
 * Agent class representing an agent in the game.
 * This class encapsulates the agent's beliefs, desires, intentions, and pathfinding logic.
 * It interacts with the game server through a client and manages its own state, including movement, exploration, and collision handling.
 * @class
 * @param {DeliverooClient} client - The client to communicate with the server.
 * @param {Me} me - The agent's own data, including its position and state.
 * @param {ParcelsStore} parcels - The parcels store to manage parcels in the game.
 * @param {MapStore} mapStore - The map store to manage the game map.
 * @param {AgentStore} agentStore - The agent store to manage other agents in the game.
 * @param {ServerConfig} serverConfig - The server configuration containing game settings.
 * @description
 * The Agent class is responsible for managing the agent's actions and interactions within the game.
 * It maintains the agent's beliefs, desires, and intentions, and uses pathfinding algorithms to navigate the game map.
 * The agent can perform actions such as picking up parcels, depositing them at bases, and exploring the map.
 * It also handles agent collisions and camping behavior based on the game state.
 * The agent's actions are logged for debugging and analysis purposes.
 */
export class Agent {
 /**
  * Agent constructor
  * @param {*} client - The client to communicate with the server
  * @param {Me} me - The agent's own data
  * @param {ParcelsStore} parcels - The parcels store to manage parcels
  * @param {MapStore} mapStore - The map store to manage the game map
  * @param {AgentStore} agentStore - The agent store to manage other agents
  * @param {ServerConfig} serverConfig - The server configuration
  * @constructor
  * @description
  * This constructor initializes the agent with the provided client, own data, parcels, map store, agent store, and server configuration.
  * It sets up the agent's beliefs, desires, intentions, and pathfinding structures.
  * It also initializes various flags and timers for movement, exploration, camping, and agent collision handling.
  */
  constructor(client, me, parcels, mapStore, agentStore, serverConfig) {
    this.client = client;
    this.me = me;
    this.parcels = parcels;
    this.mapStore = mapStore;
    this.agentStore = agentStore;
    this.serverConfig = serverConfig;

    // BDI structures
    this.desires = [];
    this.intentions = [];
    this.lastIntention = {  // Out last intentions
      type: null,
    };

    // Pathfinding 
    this.pathIndex = 0; // Move to perform (from path)
    this.path = [];     // Path got from A*

    this.oldTime = null;    // Keep track of last loop time

    this.isMoving = false;  // flag to check if it's already perfoming an action -> to not get penalties

    // Explore timers
    this.isExploring = false;
    this.isCamping = false;
    this.campingStartTime = 0;

    // Agent collision timers
    this.isColliding = false;
    this.agentCollisionStartTime = 0;

    // Log section
    this.logLevels = [];
  }


  /**
   * Logs messages based on the provided log level and arguments.
   * @param {string} logLevel - The log level to filter messages (e.g., LOG_LEVELS.MASTER, LOG_LEVELS.ACTION)
   * @param {...*} args - The arguments to log, can be any type (string, object, etc.)
   * @returns {void}
   * @description
   * This method checks if the provided log level is included in the agent's log levels.
   * If it is, it calls the `log` function with the log levels, log level, and arguments.
   * This allows for flexible logging of messages based on the agent's current log configuration.
   * @example
   * agent.log(LOG_LEVELS.MASTER, "This is a master log message");
   * @example
   * agent.log(LOG_LEVELS.ACTION, "This is an action log message", { somjeData: 123 });
   */
  log(logLevel, ...args) {
    log(this.logLevels, logLevel, ...args);
  }

/**
 * Updates the agent's beliefs based on the current state of the game.
 * This method calculates the frame difference since the last update, updates the parcels data,
 * and updates the agent's beliefs about the game state.
 * @returns {void}
 * @description
 * This method is typically called in each game loop to keep the agent's beliefs up-to-date with the current game state.
 */
  updateBeliefs() {
    // Get frame difference
    if (this.oldTime === null) {
      this.oldTime = this.me.ms;
    }
    const timeDiff = this.me.ms - this.oldTime;
    this.oldTime = this.me.ms;

    // Update parcels
    this.parcels.updateData(timeDiff / 1000, this.me.frame, this.agentStore.map.size, this.serverConfig);
  }

 /**
  * Generates desires for the agent based on the current state of the game.
  * This method evaluates the available parcels, calculates potential rewards for picking them up,
  * and considers depositing carried parcels at the nearest base.
  * @description
  * This method is called to determine the agent's desires, which are then used to form intentions.
  * It evaluates the agent's current state, including carried parcels and their rewards,
  * and generates a list of desires with associated scores.
  * It also considers the agent's position on the map and the distance to available parcels.
  * If the agent is carrying parcels, it will also consider depositing them at the nearest base.
  * Finally, it adds an intention to explore if no other intentions are more desirable.
  */
  generateDesires() {
    this.desires = [];
  

    let myParcels = this.parcels.carried(this.me.id);
    let carried_value = myParcels.reduce((sum, parcel) => sum + parcel.reward, 0);
    let carried_count = myParcels.length;
    const clockPenalty = this.serverConfig.clock / 1000;

    const roundedMe = {x : Math.round(this.me.x), y : Math.round(this.me.y)};

    // For all parcels available, calculate potential reward
    for (const p of this.parcels.available) {
      
      // Calculate distance
      const distanceToParcel = this.mapStore.distance(roundedMe, p);
      // If parcel is below us, pickup (might see some other parcel that is more valuable and leave that on the ground)
      if (distanceToParcel === 0) {
        this.desires.push({ type: INTENTIONS.GO_PICKUP, parcel: p, score: gameConfig.PICKUP_NEAR_PARCEL });
        continue;
      }

      p.calculatePotentialPickUpReward(roundedMe, true, carried_value, carried_count, this.mapStore, clockPenalty, this.serverConfig);
      
      let pickUpScoreMaster = p.potentialPickUpReward;

      this.desires.push({ type: INTENTIONS.GO_PICKUP, parcel: p, score: pickUpScoreMaster });
    }

    //If we have parcels, consider deposit option
    if (carried_count > 0) {
      let [base, minDist] = this.mapStore.nearestBase(this.me);

      // If we are on a base, drop instantly (might see some other parcel that is more valuable and not drop the parcels)
      if (minDist === 0) {
        this.desires.push({ type: INTENTIONS.GO_DEPOSIT, score: gameConfig.DEPOSIT_INSTANTLY });
      }
      else {
        let deposit_score = carried_value - minDist * carried_count * clockPenalty / this.serverConfig.parcels_decaying_interval;
  
        this.desires.push({ type: INTENTIONS.GO_DEPOSIT, score: deposit_score });
      }
    }

    // Explore come fallback
    this.desires.push({ type: INTENTIONS.EXPLORE, score: 0.0001 });
  }


  /**
   * Filters the agent's intentions based on their scores.
   * This method sorts the desires in descending order of their scores and updates the intentions list.
   * @description
   * This method is typically called after generating desires to prioritize the most desirable actions for the agent.
   * It ensures that the agent acts on the most valuable intentions first, based on their calculated scores.
   */
  filterIntentions() {
    this.intentions = this.desires.sort((a, b) => { return b.score - a.score });
  }


  /**
   * Acts on the agent's intentions.
   * This method iterates through the agent's intentions and performs actions based on their types.
   * It handles pickup, deposit, and exploration intentions, checking for conditions such as agent visibility and parcel availability.
   * @description
   * This method is called in each game loop to execute the agent's intentions based on the current game state.
   * It ensures that the agent acts on its most pressing intentions while considering the environment and other agents.
   */
  async act() {
    for (let intentionIndex = 0; intentionIndex < this.intentions.length; intentionIndex++) {

      // Get current intention
      const intention = this.intentions[intentionIndex];

      let isEqualToLastIntention = intention.type === this.lastIntention.type;

      switch (intention.type) {

        // If pickup -> check if there are other agents
        case INTENTIONS.GO_PICKUP:
          isEqualToLastIntention = isEqualToLastIntention && intention.parcel.id === this.lastIntention.parcel.id;

          let p = intention.parcel;

          let visibleAgents = this.agentStore.visible(this.me, this.serverConfig);

          if (visibleAgents.length > 0) {

            let canPickup = true;
            const myDist = this.mapStore.distance(this.me, p)

            for (let a of visibleAgents) {
              const agentDist = this.mapStore.distance(a, p);

              if (agentDist < myDist && (agentDist <= 1 || goingTowardsParcel(a, p))) {
                canPickup = false;
                break;
              }
            }

            if (!canPickup) {
              // Skip the parcel and pick the next intention in order
              continue;
            }
          }

          this.lastIntention = intention;

          // If program is here, the parcel can be pickup by us
          return this.achievePickup(p, isEqualToLastIntention, false);
        case INTENTIONS.GO_DEPOSIT:
          this.lastIntention = intention;
          return this.achieveDeposit(isEqualToLastIntention);
        case INTENTIONS.EXPLORE:
          this.lastIntention = intention;
          return this.achieveExplore(isEqualToLastIntention);
        default:
          this.lastIntention = intention;
          return;
      }
    }
  }

  /**
   * Achieves the pickup of a parcel.
   * This method moves the agent towards the specified parcel and attempts to pick it up.
   * @param {Object} p - The parcel to be picked up, containing its coordinates and ID.
   * @param {boolean} isEqualToLastIntention - Indicates if the current intention is the same as the last one.
   * @param {boolean} isFromDropped - Indicates if the pickup is from a dropped parcel.
   * @description
   * This method is called when the agent intends to pick up a parcel.
   * It checks if the agent is already at the parcel's location and emits a pickup event if so.
   * If the agent is not at the parcel's location, it calculates a new path towards the parcel and checks for collisions with other agents.
   * If the agent reaches the parcel's coordinates, it emits a pickup event to the server.
   */
  async achievePickup(p, isEqualToLastIntention, isFromDropped) {
    
    this.log(LOG_LEVELS.MASTER, "GO_PICKUP");
    
    // Move towards the parcel and pick up
    if (!isEqualToLastIntention) {
      this.getNewPath(p);
    }

    this.oneStepCheckAgents(p);
    // this.oneStep();
    if (this.me.x === p.x && this.me.y === p.y) {
      await this.client.emitPickup();
    }
  }

  /**
   * Achieves the deposit of parcels at the nearest base.
   * This method moves the agent towards the nearest base and attempts to deposit carried parcels.
   * @param {boolean} isEqualToLastIntention - Indicates if the current intention is the same as the last one.
   * @description
   * This method is called when the agent intends to deposit parcels.
   * It checks if the agent is already at the nearest base and emits a putdown event if so.
   * If the agent is not at the base, it calculates a new path towards the base and checks for collisions with other agents.
   * If the agent reaches the base's coordinates, it emits a putdown event to deposit the carried parcels.
   */
  async achieveDeposit(isEqualToLastIntention) {
    
    this.log(LOG_LEVELS.MASTER, "GO_DEPOSIT");
    
    // Use helper to move to nearest base
    if (!isEqualToLastIntention) {

      let [base, minDist] = this.mapStore.nearestBase(this.me);
      this.getBasePath(base);

      this.currentNearestBase = base;
    }

    this.oneStepCheckAgents(null);
    if (this.me.x === this.currentNearestBase.x && this.me.y === this.currentNearestBase.y) {
      this.isMoving = true;
      this.log(LOG_LEVELS.ACTION, "PUTDOWN");
      await this.client.emitPutdown(this.parcels, this.me.id);
      this.isMoving = false;
    }
  }

  /**
   * Achieves the exploration of the map.
   * This method moves the agent towards a random spawn tile and checks for camping conditions.
   * If the agent is camping, it will perform random movements until the camping conditions are no longer met.
   * @param {boolean} isEqualToLastIntention - Indicates if the current intention is the same as the last one.
   * @description
   * This method is called when the agent intends to explore the map.
   * It checks if the agent is camping on a spawn tile and updates the camping state based on the elapsed time.
   * If the agent is not camping, it calculates a new path towards a random spawn tile and checks for collisions with other agents.
   * If the agent reaches the spawn tile, it will either continue exploring or camp based on the spawn's sparsity.
   * If the agent is camping, it will perform random movements until the camping conditions are no longer met.
   */
  async achieveExplore(isEqualToLastIntention) {

    this.log(LOG_LEVELS.MASTER, "EXPLORE");

    const wasCamping = this.isCamping;

    const isOnSpawn = this.mapStore.map.get(coord2Key(this.me)) === TILE_TYPES.SPAWN;
    const spawnIsSparse = this.mapStore.isSpawnSparse;

    if (wasCamping) {
      const secondsElapsed = (Date.now() - this.campingStartTime) / 1000;
      this.isCamping = spawnIsSparse && (secondsElapsed < config.CAMP_TIME);
    }
    else {
      this.isCamping = !this.isExploring && spawnIsSparse && isOnSpawn;
    }

    // If spawn is not sparse OR we are not on a green tile
    if (!this.isCamping) {
      
      if (!isEqualToLastIntention || wasCamping) {
        let spawnTileCoord = this.mapStore.randomSpawnTile(this.me);
        this.isExploring = true;
        this.getNewPath(spawnTileCoord);
      }

      this.oneStepCheckAgents(null);
      if (this.pathIndex >= this.path.length) {
        this.isExploring = false;
      }
    }
    // Camping behaviour
    else {
      if (!wasCamping) {
        this.campingStartFrame = Date.now();
      }

      this.isMoving = true;
      await randomMoveAndBack(this.client, this.me, this.mapStore);
      this.isMoving = false;
    }
  }

/**
 * Checks for agent collisions and performs one step of the agent's path.
 * This method checks if the agent is colliding with another agent in the next tile of its path.
 * If a collision is detected, it sets the colliding flag and starts a timer.
 * If the timer expires, it gets a new path based on the last intention.
 * If there are no collisions, it performs one step of the agent's path.
 * @param {Object} newPathTile - The tile to which the agent should move if a collision is detected.
 * @description
 * This method is called in each game loop to handle agent movement and collision detection.
 * It ensures that the agent can navigate the map while avoiding collisions with other agents.
 * If a collision is detected, it will wait for a specified time before attempting to get a new path.
 * If no collisions are detected, it will proceed with the next step in the agent's path.
 */
  async oneStepCheckAgents(newPathTile) {

    if (this.pathIndex >= this.path.length) {
      this.lastIntention = { type: null };
      return;
    }

    const visibleAgents = this.agentStore.visible(this.me, this.serverConfig);

    // Check if any agent is in the tile i want to go
    const nextTile = this.path[this.pathIndex];

    for (let a of visibleAgents) {
      // If there's an agent in the tile i want to go
      if (a.x === nextTile.x && a.y === nextTile.y) {

        // Set colliding (only first time)
        if (!this.isColliding) {
          this.isColliding = true;
          this.agentCollisionStartTime = Date.now();
        }

        // After timer expires -> get new path
        const secondsElapsed = (Date.now() - this.agentCollisionStartTime) / 1000;
        if (secondsElapsed > config.AGENT_TIME) {
          this.isColliding = false;

          switch (this.lastIntention.type) {
            case INTENTIONS.GO_PICKUP :
              break;
            case INTENTIONS.GO_DEPOSIT :
              const [base, minDist] = this.mapStore.nearestBase(this.me);
              this.currentNearestBase = base;
              newPathTile = base;
              break;
            case INTENTIONS.EXPLORE :
              const spawnTileCoord = this.mapStore.randomSpawnTile(this.me);
              this.isExploring = true;
              newPathTile = spawnTileCoord;
              break;
            default :
              break;
          }

          if (this.lastIntention.type === INTENTIONS.GO_DEPOSIT) {
            this.getBasePath(newPathTile);
          }
          else {
            this.getNewPath(newPathTile);
          }
        }

        return;
      }
    }

    this.isColliding = false;

    // If everything is clear -> move
    await this.oneStep();
  }

/**
 * Performs one step of the agent's path.
 * This method moves the agent towards the next tile in its path and updates the path index.
 * If the agent has reached the end of its path, it resets the last intention.
 * @description
 * This method is called in each game loop to move the agent towards its next destination.
 * It calculates the direction to the next tile in the path and performs the movement action.
 * If the agent has reached the end of its path, it resets the last intention to indicate that no further action is needed. 
 */
  async oneStep() {
    if (this.pathIndex >= this.path.length) {
      this.lastIntention = { type: null };
      return;
    }

    this.isMoving = true;

    const dir = direction(this.me, this.path[this.pathIndex]);
    if (dir) {
      this.log(LOG_LEVELS.ACTION, "Moving ", dir);
      await moveAndWait(this.client, this.me, dir);
    }

    this.isMoving = false;

    this.pathIndex++;
  }

/**
 * Get A* path to a target tile
 * @param {{x : number, y : number}} target - The target coordinates to which the agent should find a path.
 * @description
 * This method calculates a path from the agent's current position to the specified target using the A* algorithm.
 * It initializes the path index to 0 and stores the calculated path in the agent's path property.
 * The path is used to determine the agent's movement towards the target tile.
 * @example
 * agent.getPath({ x: 5, y: 10 });
 */
  getPath(target) {
    this.pathIndex = 0;
    this.path = astarSearch({ x: Math.round(this.me.x), y: Math.round(this.me.y) }, target, this.mapStore);
  }

/**
 * Get A* path to a target tile, removing visible agents from the map
 * @param {{x : number, y : number}} target - The target coordinates to which the agent should find a path.
 * @description
 * This method calculates a path from the agent's current position to the specified target using the A* algorithm.
 * It initializes the path index to 0 and temporarily removes visible agents from the map to avoid collisions.
 * The path is then calculated and stored in the agent's path property.
 * After the path is calculated, the removed agents are restored to their original tile types.
 * This allows the agent to find a path without being blocked by other agents while still considering their presence in the game.
 * @example
 * agent.getNewPath({ x: 5, y: 10 });
 * */
  getNewPath(target) {
    this.pathIndex = 0;

    let tileMapTemp = new Map();

    // Remove tiles with agents
    for (const a of this.agentStore.visible(this.me, this.serverConfig)) {
      let type = this.mapStore.setType(a, TILE_TYPES.EMPTY);
      tileMapTemp.set(coord2Key(a), type);
    }

    this.path = astarSearch({ x: Math.round(this.me.x), y: Math.round(this.me.y) }, target, this.mapStore);

    // Re-add tiles
    for (const [key, value] of tileMapTemp) {
      let tile = key2Coord(key);
      this.mapStore.setType(tile, value);
    }
  }

  /**
   * Get a path to the nearest base, removing the current base from the map if necessary.
   * @param {{x : number, y : number}} target - The target coordinates of the nearest base to which the agent should find a path.
   * @description
   * This method attempts to find a path to the nearest base tile.
   * If the agent is already at the target base, it returns immediately.
   * If the agent is not at the target base, it checks if the path to the base is clear.
   * If the path is not clear, it removes the current base from the map and waits for a specified time before restoring it.
   * It then finds a new target base and recalculates the path to it.
   * This process continues until a valid path is found or the maximum number of tries is reached.
   * @example
   * agent.getBasePath({ x: 10, y: 15 });
   */
  getBasePath(target) {
    let tries = 0;

    do {
      // Check if #tries expired
      if (tries % config.BASE_TRIES === 0 && tries > 0) {
        
        // Delete base from map
        this.mapStore.setType(target, TILE_TYPES.EMPTY);

        // Re-add seconds later
        const restoreTarget = { x: target.x, y: target.y };  // snapshot
        setTimeout(() => {
          this.mapStore.setType(restoreTarget, TILE_TYPES.BASE);
        }, config.BASE_REMOVAL_TIME);

        // Find new target
        const [base, minDist] = this.mapStore.nearestBase(this.me);

        if (base === null || base === undefined) {
          return;
        }

        this.currentNearestBase = base;

        target = base;
      }

      if (this.me.x === target.x && this.me.y === target.y)
        return;

      // Check if it exists a path to the nearest base (without the one removed from the map)
      this.getNewPath(target);

      tries++;

    } while (this.path.length === 0 && tries < config.BASE_SWITCH_MAX_TRIES);
  }
}