pieces/agents/targeting.ts

import { Agent } from "../../core/agent.ts";
import { Board } from "../../core/board.ts";
import { Cell } from "../../core/cell.ts";
import type { Player } from "../../core/player.ts";
import {
  ADJ_DIRECTIONS,
  Direction,
  EAST,
  NORTH,
  NORTHEAST,
  NORTHWEST,
  SOUTH,
  SOUTHEAST,
  SOUTHWEST,
  WEST,
} from "../../core/direction.ts";
import { AMMUNITION, CARNIVORE, PLAYER, RANGED_WEAPON } from "../../core/flags.ts";

const LEFT_FIRST = [-1, 1, -2, 2, -3, 3];
const RIGHT_FIRST = [1, -1, 2, -2, 3, -3];

/**
 * Targeting — describes how an agent chooses a movement or attack direction.
 * Mirrors the Java Targeting parameter object / builder pattern.
 *
 * Builder methods set up the targeting; read properties expose the result.
 * The naming asymmetry (attackPlayer() method vs attacksPlayer property) avoids
 * a conflict between the builder method and its backing field.
 */
export class Targeting {
  range: number;
  distance: number;
  attacksPlayer: boolean;
  fleesPlayer: boolean;
  movesRandom: boolean;
  tracksPlayer: boolean;
  bulletDodgePct: number;

  constructor() {
    this.range = 1;
    this.distance = -1;
    this.attacksPlayer = false;
    this.fleesPlayer = false;
    this.movesRandom = false;
    this.tracksPlayer = false;
    this.bulletDodgePct = 0;
  }

  attackPlayer(range: number): Targeting {
    this.attacksPlayer = true;
    this.range = range;
    return this;
  }
  fleePlayer(range: number): Targeting {
    this.fleesPlayer = true;
    this.range = range;
    return this;
  }
  moveRandomly(): Targeting {
    this.movesRandom = true;
    return this;
  }
  trackPlayer(): Targeting {
    this.tracksPlayer = true;
    return this;
  }
  keepDistance(d: number): Targeting {
    this.distance = d;
    return this;
  }
  dodgeBullets(pct: number): Targeting {
    this.bulletDodgePct = pct;
    return this;
  }
}

export function getDistance(cell1: Cell, cell2: Cell) {
  const dx = cell2.x - cell1.x;
  const dy = cell2.y - cell1.y;
  return Math.sqrt(dx * dx + dy * dy);
}

export function getDirectionToCell(origin: Cell, target: Cell) {
  const dx = target.x - origin.x;
  const dy = target.y - origin.y;
  if (dx === 0 && dy === 0) return null;
  // Mirrors Java AgentUtils.getDirectionToCell — compare signs of dx/dy
  if (dx === 0 && dy < 0) return NORTH;
  if (dx === 0 && dy > 0) return SOUTH;
  if (dx > 0 && dy === 0) return EAST;
  if (dx < 0 && dy === 0) return WEST;
  if (dx < 0 && dy < 0) return NORTHWEST;
  if (dx > 0 && dy > 0) return SOUTHEAST;
  if (dx > 0 && dy < 0) return NORTHEAST;
  return SOUTHWEST;
}

function isStraightPath(cell1: Cell, cell2: Cell) {
  const d = getDistance(cell1, cell2);
  if (d <= 1) return false;
  return (
    cell1.x === cell2.x ||
    cell1.y === cell2.y ||
    cell1.x - cell2.x === cell1.y - cell2.y
  );
}

function getDirectionAtOffset(dir: Direction, offset: number): Direction | null {
  const idx = ADJ_DIRECTIONS.indexOf(dir);
  if (idx === -1) {
    return null;
  }
  const n = ADJ_DIRECTIONS.length;
  return ADJ_DIRECTIONS[(((idx + offset) % n) + n) % n];
}

/**
 * Can the agent move from `from` in direction `dir`? Mirrors Java
 * AgentUtils.isMoveViable exactly, including the "don't move into the
 * player's line of fire" and "don't wander into a bullet" checks.
 *
 * `targetCell` is the cell the agent is ultimately trying to reach (may be
 * null, e.g. when falling back to breadcrumb tracking or random movement —
 * in which case, as in Java, the LOF-dodge check is skipped since it isn't
 * about the player).
 */
function isMoveViable(
  board: Board,
  from: Cell,
  agent: Agent,
  targetCell: Cell | null,
  dir: Direction,
  targeting: Targeting,
): boolean {
  const next = board.getAdjacentCell(from.x, from.y, dir);
  if (!next) {
    return false;
  }
  if (from.terrain && !from.terrain.canExit(agent, from, dir)) return false;

  const isAttackingPlayer = targeting.attacksPlayer && !!next.agent?.is(PLAYER);
  if (!next.canEnter(from, agent, dir, isAttackingPlayer)) {
    return false;
  }
  // Avoid moving into the player's line of fire — but only if the agent's
  // actual target is the player, and only if the player is wielding a
  // ranged weapon or a grenade. Mirrors Java's isMoveViable exactly.
  if (
    targetCell &&
    targetCell.agent?.is(PLAYER) &&
    Math.random() * 100 < targeting.bulletDodgePct
  ) {
    const player = targetCell.agent as unknown as Player;
    const selected = player.bag.getSelected();
    if (
      (selected.is(RANGED_WEAPON) || selected.name === "Grenade") &&
      isStraightPath(next, targetCell)
    ) {
      return false;
    }
  }
  // Don't wander into a bullet (or other in-flight ammunition effect).
  for (const effect of next.effects ?? []) {
    if (effect.is(AMMUNITION)) {
      return false;
    }
  }

  return true;
}

export function findPathInDirection(
  board: Board,
  cell: Cell,
  agent: Agent,
  targetCell: Cell | null,
  direction: Direction,
  targeting: Targeting,
): Direction | null {
  if (isMoveViable(board, cell, agent, targetCell, direction, targeting)) {
    return direction;
  }
  const adjustments = Math.random() < 0.5 ? LEFT_FIRST : RIGHT_FIRST;
  for (const adj of adjustments) {
    const sideways = getDirectionAtOffset(direction, adj);
    if (
      sideways &&
      isMoveViable(board, cell, agent, targetCell, sideways, targeting)
    ) {
      return sideways;
    }
  }
  return null;
}

export function getRandomDirection() {
  return ADJ_DIRECTIONS[Math.floor(Math.random() * ADJ_DIRECTIONS.length)];
}

/**
 * Determine the direction an agent should move given its targeting spec.
 * Returns a Direction or null if no move is possible.
 *
 * Mirrors Java AgentUtils.findPathToTarget. There are two ways a target can
 * be found:
 *  - the "direct" fast path, used only when the agent is attacking the
 *    player, isn't a CARNIVORE (which might prefer nearby meat) and isn't
 *    tracking breadcrumbs — this compares straight-line (Euclidean)
 *    distance to the player against the range.
 *  - the "visitor" path (everything else), which scans outward ring by
 *    ring — closest first — via board.visitRange, mirroring
 *    TargetingVisitor's per-cell priority: carnivore meat first, then the
 *    player (attack or flee), then (only in the ring immediately adjacent
 *    to the agent) a breadcrumb trail. The first match wins, so distance
 *    to the found cell is the ring index, not the true Euclidean distance.
 */
export function findPathToTarget(board: Board, agentCell: Cell, agent: Agent, targeting: Targeting) {
  const playerCell = board.getCurrentCell();
  let targetCell: Cell | null = null;
  let dir: Direction | null;
  let actualDistance: number;
  let mostRecentlyVisited: Cell | null = null;

  const targetsPlayerDirectly =
    targeting.attacksPlayer && agent.not(CARNIVORE) && !targeting.tracksPlayer;

  if (targetsPlayerDirectly) {
    targetCell = playerCell;
    actualDistance = getDistance(agentCell, playerCell);
    dir = actualDistance <= targeting.range ? getDirectionToCell(agentCell, playerCell) : null;
  } else {
    let foundDistance = 0;
    board.visitRange(agentCell, targeting.range, false, (cell, dist) => {
      if (agent.is(CARNIVORE) && !cell.isBagEmpty && cell.containsMeat()) {
        targetCell = cell;
        foundDistance = dist;
        return false;
      }
      if ((targeting.fleesPlayer || targeting.attacksPlayer) && cell.agent?.is(PLAYER)) {
        targetCell = cell;
        foundDistance = dist;
        return false;
      }
      if (targeting.tracksPlayer && dist === 1 && cell.visited !== 0) {
        if (mostRecentlyVisited === null || cell.visited > mostRecentlyVisited.visited) {
          mostRecentlyVisited = cell;
        }
      }
      return true;
    });
    dir = targetCell ? getDirectionToCell(agentCell, targetCell) : null;
    actualDistance = foundDistance;
  }

  if (dir) {
    // Flee: always reverse direction; keepDistance: reverse only when too close.
    if (targeting.fleesPlayer || actualDistance <= targeting.distance) {
      dir = dir.reverse;
    }
    const found = findPathInDirection(board, agentCell, agent, targetCell, dir, targeting);
    if (found) return found;
  }

  if (targeting.tracksPlayer) {
    const trackDir = mostRecentlyVisited
      ? getDirectionToCell(agentCell, mostRecentlyVisited)
      : null;
    if (trackDir && isMoveViable(board, agentCell, agent, targetCell, trackDir, targeting)) {
      return trackDir;
    }
  }

  if (targeting.movesRandom) {
    const randDir = getRandomDirection();
    if (isMoveViable(board, agentCell, agent, targetCell, randDir, targeting)) {
      return randDir;
    }
  }
  return null;
}