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;
}