pieces/agents/creatures.ts

import { Piece } from "../../core/piece.ts";
import { Agent } from "../../core/agent.ts";
import { AgentProxy } from "../../core/agent-proxy.ts";
import { Registry } from "../../core/registry.ts";
import { game } from "../../core/game.ts";
import { events } from "../../core/event-bus.ts";
import { EMPTY_HANDED, Player } from "../../core/player.ts";
import { Targeting, findPathInDirection, getRandomDirection } from "./targeting.ts";
import { TerrainUtils } from "../../core/terrain-utils.ts";
import { Animated, isAnimated } from "../../core/animated.ts";
import {
  ORGANIC,
  PENETRABLE,
  ETHEREAL,
  FLIER,
  CARNIVORE,
  PUSHABLE,
  FIRE_RESISTANT,
  LAVITIC,
  AQUATIC,
  PLAYER,
  WEAK,
  NOT_EDITABLE,
  PARALYZED,
  THEFT_RESISTANT,
  AMMUNITION,
  PARALYSIS_RESISTANT,
  STONING_RESISTANT,
  TURNED_TO_STONE,
} from "../../core/flags.ts";
import {
  BLACK,
  BLUE,
  BROWN,
  BUILDING_FLOOR,
  BUILDING_WALL,
  CYAN,
  Color,
  DARKBLUE,
  DARKGOLDENROD,
  DARKGREEN,
  DARKOLIVEGREEN,
  DARKORANGE,
  DARKORCHID,
  DARKSEAGREEN,
  DARKSLATEBLUE,
  DARKVIOLET,
  FIREBRICK,
  GOLD,
  GREENYELLOW,
  LESSNEARBLACK,
  LIGHTSTEELBLUE,
  MAROON,
  MEDIUMSEAGREEN,
  MIDNIGHTBLUE,
  NONE,
  OLIVEDRAB,
  ORANGE,
  ORCHID,
  POWDERBLUE,
  RED,
  SADDLEBROWN,
  SALMON,
  SEAGREEN,
  SILVER,
  SLATEBLUE,
  VIOLET,
  WHITE,
  YELLOW,
  colorByName,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import { TypeOnlySerializer, BaseSerializer, Serializer } from "../../core/serializer.ts";
import { State, stateFromString } from "../../core/state.ts";

import {
  directionByName,
  ADJ_DIRECTIONS,
  NORTH,
  SOUTH,
  EAST,
  WEST,
  Direction,
} from "../../core/direction.ts";
import { Cell } from "../../core/cell.ts";
import { GameEvent } from "../../core/game-event.ts";
import { Agentray, Arrow, Fireball, Parabullet, PoisonDart, Stoneray, Weakray } from "../items/items.ts";
import { Item } from "../../core/item.ts";
import { Hit } from "../effects/effects.ts";

function colorSerializer(typeId: string, Cls: new (color: Color) => Piece): Serializer {
  return new (class extends BaseSerializer {
    create([color]: Array<string | Piece>) {
      return new Cls(colorByName(color as string) ?? NONE);
    }
    store(a: Piece) {
      return `${typeId}|${(a as any).color.name}`;
    }
    example() {
      return new Cls(NONE);
    }
    template() {
      return `${typeId}|{color}`;
    }
    tag() {
      return "Agents";
    }
  })();
}

const INDESTRUCTIBLE = -500;

const CTBH = {
  ASCIIROTH: -20,
  CEPHALID: 0,
  CORVID: 25,
  FARTHAPOD: 15,
  HOOLOOVOO: -20,
  KILLER_BEE: 5,
  LAVA_WORM: 25,
  LIGHTNING_LIZARD: 25,
  OPTILISK: 25,
  RHINDLE: 15,
  SLEESTAK: 15,
  TETRITE: 25,
  THERMADON: 25,
  TRIFFID: 15,
};

const DAMAGE = {
  ASCIIROTH: 50,
  BOULDER: 30,
  CORVID: 5,
  FARTHAPOD: 5,
  GREAT_OLD_ONE: 130,
  HOOLOOVOO: 20,
  KILLER_BEE: 10,
  LAVA_WORM: 20,
  LIGHTNING_LIZARD: 3,
  OPTILISK: 40,
  PUSHER: 3,
  RHINDLE: 30,
  SLEESTAK: 30,
  TETRITE: 10,
  THERMADON: 20,
  TRIFFID: 20,
};

/**
 * Base for all game agents. Implements the health-as-hit-chance mechanic:
 * `chanceToHit` is a baseline %; weapons add their damage value. If the
 * resulting roll succeeds the agent is destroyed (returns 0).
 *
 * INDESTRUCTIBLE agents have chanceToHit = -500 (virtually impossible to kill).
 */
export class AbstractAgent extends Agent {
  chanceToHit: number;

  constructor(name: string, flags: number, color: Color, chanceToHit: number, symbol: Sym) {
    super(name, flags, color, symbol);
    this.chanceToHit = chanceToHit;
  }

  changeHealth(delta: number): number {
    const test = this.chanceToHit + delta;
    return Math.random() * 100 <= test ? 0 : 1;
  }

  onDie(event: GameEvent, cell: Cell) {
    // Fire this agent's color event on death (mirrors AbstractAgent.onDie)
    if (this.color !== NONE) {
      event.board.fireColorEvent(event, this.color, cell);
    }
  }

  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    if (agent.is(PLAYER) && this.is(PUSHABLE)) {
      if (agent.is(WEAK)) {
        event.cancel("You're too weak to push anything");
      } else {
        // agentLoc = attacker's cell (Java convention); our cell = agentLoc + dir
        const myCell = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir)!;
        game.agentMoveInDirection(event, myCell, this, dir);
      }
    } else if (
      agent instanceof RollingBoulder &&
      !(this instanceof ImmobileAgent)
    ) {
      const myCell = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir)!;
      game.agentMoveInDirection(event, myCell, this, dir);
      if (event.isCancelled) {
        // Trapped between the rolling boulder and an obstacle: crushed.
        // Mirrors Game.damage(cell, this, 500), which is changeHealth(500)
        // followed by die() (onDie + removeAgent + Hit effect) on death.
        if (this.changeHealth(500) === 0) {
          this.onDie(event, myCell);
          myCell.removeAgent(this);
          myCell.addEffect(new Hit(this));
        }
      }
    } else {
      event.cancel();
    }
  }
}

/** An agent that cannot be moved or destroyed. */
export class ImmobileAgent extends AbstractAgent {
  constructor(name: string, flags: number, symbol: Sym) {
    super(name, flags, NONE, INDESTRUCTIBLE, symbol);
  }
  changeHealth(value: number): number {
    return 100;
  }
}

export class Tree extends ImmobileAgent {
  constructor(name: string, symbol: Sym) {
    super(name, ORGANIC, symbol);
  }
}

export class AbstractBoulder extends AbstractAgent {
  constructor(name: string, flags: number, color: Color, chanceToHit: number, symbol: Sym) {
    super(name, flags, color, chanceToHit, symbol);
  }
}

export class Boulder extends AbstractBoulder {
  constructor() {
    super(
      "Boulder",
      PUSHABLE,
      NONE,
      INDESTRUCTIBLE,
      Sym.of("O", DARKGOLDENROD),
    );
  }
  changeHealth(v: number): number {
    return 100;
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Boulder");
    }
    create() {
      return new Boulder();
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class RollingBoulder extends AbstractBoulder implements Animated {
  direction: Direction;
  state: State;
  constructor(direction: Direction, color: Color, state: State) {
    super("Rolling Boulder", 0, color, INDESTRUCTIBLE, Sym.of("O", BROWN));
    this.direction = direction;
    this.state = state;
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, boulderCell: Cell, playerCell: Cell, player: Player) {
    // Deal rolling damage; if it kills the player stop here.
    if (player.changeHealth(DAMAGE.BOULDER) === 0) return;
    // Push the player one step further in the rolling direction if possible.
    const beyond = playerCell.getAdjacentCell(this.direction);
    if (beyond && beyond.canEnter(playerCell, player, this.direction)) {
      game.movePlayer(this.direction);
    } else {
      // Player is trapped between boulder and obstacle — crush damage.
      player.changeHealth(500);
    }
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (this.state.isOn() && frame % 5 === 0) {
      const event = game.createEvent();
      game.agentMoveInDirection(event, cell, this, this.direction);
    }
  }
  onColorEvent(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && this.state.isOff()) {
      cell.removeAgent(this);
      const next = Registry.agent(
        `RollingBoulder|${this.direction.name}|${this.color.name}|on`,
      );
      cell.setAgent(next);
    }
  }
  changeHealth(v: number): number {
    return 100;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir, color, state]: string[]) {
      return new RollingBoulder(
        directionByName(dir) ?? EAST,
        colorByName(color) ?? NONE,
        stateFromString(state),
      );
    }
    store(b: RollingBoulder) {
      return `RollingBoulder|${b.direction.name}|${b.color.name}|${b.state}`;
    }
    example() {
      return new RollingBoulder(EAST, NONE, stateFromString("off"));
    }
    template() {
      return "RollingBoulder|{direction}|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Pusher extends AbstractAgent implements Animated {
  static SYMBOLS = {
    [NORTH.name]: "▲",
    [SOUTH.name]: "▼",
    [EAST.name]: "►",
    [WEST.name]: "◄",
  };
  direction: Direction;
  state: State;
  constructor(direction: Direction, color: Color, state: State) {
    const glyph = Pusher.SYMBOLS[direction.name];
    super(
      "Pushable",
      0,
      color,
      INDESTRUCTIBLE,
      Sym.of(glyph, LIGHTSTEELBLUE, null, MIDNIGHTBLUE, null),
    );
    this.direction = direction;
    this.state = state;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.PUSHER);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (this.state.isOn() && frame % 6 === 0) {
      const event = game.createEvent();
      game.agentMoveInDirection(event, cell, this, this.direction);
    }
  }
  onColorEvent(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && this.state.isOff()) {
      cell.removeAgent(this);
      const next = Registry.agent(`Pusher|${this.direction.name}|${this.color.name}|on`);
      cell.setAgent(next);
    }
  }
  randomSeed() {
    return false;
  }
  changeHealth(v: number): number {
    return 100;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir, color, state]: string[]) {
      return new Pusher(
        directionByName(dir) ?? NORTH,
        colorByName(color) ?? NONE,
        stateFromString(state),
      );
    }
    store(p: Pusher) {
      return `Pusher|${p.direction.name}|${p.color.name}|${p.state}`;
    }
    example() {
      return new Pusher(NORTH, NONE, stateFromString("off"));
    }
    template() {
      return "Pusher|{direction}|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Slider extends AbstractAgent {
  direction: Direction;
  constructor(direction: Direction) {
    const glyph = direction === NORTH ? "↕" : "↔";
    super(
      "Slider",
      PUSHABLE,
      NONE,
      INDESTRUCTIBLE,
      Sym.of(glyph, VIOLET, null, MAROON, null),
    );
    this.direction = direction;
  }
  changeHealth(v: number): number {
    return 100;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    if (
      (this.direction.isNorthSouth() && dir.isNorthSouth()) ||
      (this.direction.isEastWest() && dir.isEastWest())
    ) {
      // agentLoc = attacker's cell (Java convention); our cell = agentLoc + dir
      const myCell = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir)!;
      game.agentMoveInDirection(event, myCell, this, dir);
    } else {
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir]: string[]) {
      return new Slider(directionByName(dir) ?? NORTH);
    }
    store(s: Slider) {
      return `Slider|${s.direction.name}`;
    }
    example() {
      return new Slider(NORTH);
    }
    template() {
      return "Slider|{direction}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/** A campfire. It blocks movement but projectiles can be thrown across it. */
export class Campfire extends AbstractAgent implements Animated {
  static SYMBOLS = [
    Sym.of("ω", RED),
    Sym.of("ω", ORANGE),
    Sym.of("ω", RED),
    Sym.of("ω", YELLOW),
  ];

  constructor() {
    super(
      "Campfire",
      PENETRABLE | ETHEREAL,
      NONE,
      INDESTRUCTIBLE,
      Sym.of("ω", RED),
    );
  }
  randomSeed(): boolean {
    return true;
  }
  changeHealth(v: number): number {
    return 100;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 === 0) {
      cell.animAgentSymbol = Campfire.SYMBOLS[Math.floor(frame / 4) % 4];
      cell.board.notifyCellChange(cell);
    }
  }

  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Campfire");
    }
    create() {
      return new Campfire();
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Pillar extends ImmobileAgent {
  constructor() {
    super(
      "Pillar",
      0,
      Sym.of("¶", LESSNEARBLACK, null, BUILDING_WALL, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Pillar");
    }
    create() {
      return new Pillar();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * A boss piece. It can only be killed with Terminus Est, and although this
 * alone is not too difficult, it is accompanied by attendants who can be
 * deadly, and is sure to weaken the character via Weakray clouds. Asciiroth
 * runs away and keeps its distance.
 *
 * Asciiroth should be placed on the board with "attendant" agents of the
 * same color. When Asciiroth dies (or a color event of its own color
 * fires), it shoots an Agentray that replaces whatever it hits with a
 * fresh copy of its attendant agent.
 */
export class Asciiroth extends AbstractAgent implements Animated {
  static SYMBOLS = [
    Sym.of("א", CYAN),
    Sym.of("א", DARKSEAGREEN),
    Sym.of("א", GREENYELLOW),
  ];
  #movTargeting: Targeting;
  #fireTargeting: Targeting;
  #weakray: Weakray;
  agentray: Agentray;
  constructor(color: Color, agent: Agent) {
    super(
      "Asciiroth",
      PARALYSIS_RESISTANT | STONING_RESISTANT | FIRE_RESISTANT,
      color,
      CTBH.ASCIIROTH,
      Asciiroth.SYMBOLS[0],
    );
    this.#movTargeting = new Targeting()
      .attackPlayer(25)
      .keepDistance(6)
      .moveRandomly();
    this.#fireTargeting = new Targeting().attackPlayer(20);
    this.#weakray = Registry.item("Weakray") as Weakray;
    this.agentray = new Agentray(agent);
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.ASCIIROTH);
  }
  onDie(event: GameEvent, cell: Cell) {
    this.#shootAgentRay(event, cell);
    super.onDie(event, cell);
  }
  onColorEvent(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color) {
      this.#shootAgentRay(event, cell);
    }
  }
  #shootAgentRay(event: GameEvent, cell: Cell) {
    // If an agentray bullet is stopped by a wall adjoining Asciiroth, no
    // creature is created. To prevent this, keep picking random directions
    // until one is viable.
    let dir: Direction | null = null;
    let next: Cell | null = null;
    while (dir == null || next == null || !next.canEnter(cell, this, dir, false)) {
      dir = getRandomDirection();
      next = cell.getAdjacentCell(dir);
    }
    const e = game.createEvent();
    game.shoot(e, cell, this, this.agentray, dir);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 === 0) {
      game.agentMove(cell, this, this.#movTargeting);
    } else if (frame % 11 === 0) {
      game.agentShoot(cell, this, this.#weakray, this.#fireTargeting);
    } else {
      cell.animAgentSymbol = Asciiroth.SYMBOLS[frame % 3];
      cell.board.notifyCellChange(cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, agentArg]: string[]) {
      const agent = Registry.agent(agentArg);
      return new Asciiroth(colorByName(color as string) ?? NONE, agent);
    }
    store(a: Asciiroth) {
      return `Asciiroth|${a.color.name}|${this.esc(a.agentray.agent)}`;
    }
    example() {
      return new Asciiroth(NONE, new LightningLizard(NONE));
    }
    template() {
      return "Asciiroth|{color}|{agent}";
    }
    tag() {
      return "Agents";
    }
  })();
}

export class Cephalid extends AbstractAgent implements Animated {
  #movTargeting: Targeting;
  #shootTargeting: Targeting;
  constructor(color: Color) {
    super(
      "Cephalid",
      ORGANIC,
      color,
      CTBH.CEPHALID,
      Sym.of("€", ORCHID, null, DARKORCHID, null),
    );
    this.#movTargeting = new Targeting()
      .attackPlayer(14)
      .moveRandomly()
      .trackPlayer();
    this.#shootTargeting = new Targeting().attackPlayer(14);
  }
  randomSeed(): boolean {
    return true;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    if (agent.is(ORGANIC) && !(agent instanceof AgentProxy)) {
      if (agent.is(PLAYER)) {
        const player = event.player;
        if (
          player.testResistance(PARALYSIS_RESISTANT) ||
          player.testResistance(STONING_RESISTANT)
        ) {
          return;
        }
        player.add(TURNED_TO_STONE);
      }
      agentLoc.setAgent(new Statue(agent, this.color));
    }
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 5 === 0) game.agentMove(cell, this, this.#movTargeting);
    else if (frame % 7 === 0) {
      if (game.player?.bag.getSelected().name !== "The Mirror Shield") {
        game.agentShoot(cell, this, Registry.item("Stoneray"), this.#shootTargeting);
      }
    }
  }
  static SERIALIZER = colorSerializer("Cephalid", Cephalid);
}

export class Corvid extends AbstractAgent implements Animated {
  static SYMBOLS = [
    Sym.of("∧", POWDERBLUE, null, MIDNIGHTBLUE, null),
    Sym.of("∨", POWDERBLUE, null, MIDNIGHTBLUE, null),
  ];
  static STEALING = new Targeting()
    .attackPlayer(12)
    .moveRandomly()
    .dodgeBullets(60);
  static RUNNING = new Targeting().fleePlayer(12).dodgeBullets(60);
  item: Item | null;

  constructor(color: Color, item: Item | null = null) {
    super("Corvid", FLIER | ORGANIC, color, CTBH.CORVID, Corvid.SYMBOLS[0]);
    this.item = item;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    const player = event.player;
    const held = player.bag.getSelected();
    if (held !== EMPTY_HANDED) {
      if (player.testResistance(THEFT_RESISTANT)) {
        return;
      }
      const e = game.createEvent();
      const cell = e.board.getCurrentCell();
      held.onDeselect(e, cell);
      if (e.isCancelled) {
        return;
      }
      player.bag.remove(held);
      events.fireModalMessage(
        `The corvid snatches the ${held.name} from your hands!`,
      );
      attackerLoc.removeAgent(this);
      attackerLoc.setAgent(new Corvid(this.color, held));
    } else {
      event.player.changeHealth(DAMAGE.CORVID);
    }
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  randomSeed(): boolean {
    return true;
  }
  onDie(event: GameEvent, cell: Cell) {
    if (this.item) {
      cell.addItem(this.item);
    }
    super.onDie(event, cell);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    cell.animAgentSymbol = Corvid.SYMBOLS[frame % 2];
    cell.board.notifyCellChange(cell);
    if (frame % 3 === 0) {
      game.agentMove(
        cell,
        this,
        this.item ? Corvid.RUNNING : Corvid.STEALING,
      );
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, itemArg]: string[]) {
      const item = typeof itemArg === "string" ? Registry.item(itemArg) : itemArg;
      return new Corvid(colorByName(color) ?? NONE, item);
    }
    store(c: Corvid) {
      return c.item
        ? `Corvid|${c.color.name}|${this.esc(c.item)}`
        : `Corvid|${c.color.name}`;
    }
    example() {
      return new Corvid(NONE, null);
    }
    template() {
      return "Corvid|{color}|{item?}";
    }
    tag() {
      return "Agents";
    }
  })();
}

export class Farthapod extends AbstractAgent implements Animated {
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Farthapod",
      CARNIVORE | ORGANIC,
      color,
      CTBH.FARTHAPOD,
      Sym.of("¤", SILVER, null, DARKSLATEBLUE, null),
    );
    this.#targeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(7)
      .moveRandomly()
      .trackPlayer();
  }
  randomSeed(): boolean {
    return true;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.FARTHAPOD);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 5 === 0) {
      game.agentMove(cell, this, this.#targeting);
    }
  }
  static SERIALIZER = colorSerializer("Farthapod", Farthapod);
}

export class GreatOldOne extends AbstractAgent implements Animated {
  static SYMBOLS = [
    Sym.of("Ⲝ", WHITE, null, BLACK, null),
    Sym.of("ξ", WHITE, null, BLACK, null)
  ];
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Great Old One",
      0,
      color,
      INDESTRUCTIBLE,
      GreatOldOne.SYMBOLS[0],
    );
    this.#targeting = new Targeting()
      .attackPlayer(12)
      .moveRandomly()
      .trackPlayer();
  }
  randomSeed(): boolean {
    return true;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.GREAT_OLD_ONE);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 3 === 0) {
      cell.animAgentSymbol = GreatOldOne.SYMBOLS[frame % 2];
      cell.board.notifyCellChange(cell);
    }
    if (frame % 5 === 0) {
      game.agentMove(cell, this, this.#targeting);
    }
  }
  static SERIALIZER = colorSerializer("GreatOldOne", GreatOldOne);
}

export class Hooloovoo extends AbstractAgent implements Animated {
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Hooloovoo",
      ORGANIC,
      color,
      CTBH.HOOLOOVOO,
      Sym.of("H", SLATEBLUE, DARKSLATEBLUE),
    );
    this.#targeting = new Targeting().attackPlayer(8).moveRandomly();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    const player = event.player;
    if (player.bag.size() > 1) {
      if (player.testResistance(THEFT_RESISTANT)) {
        return;
      }
      while (player.bag.size() > 1) {
        // given bag size, there's an item
        const item = (player.bag.last() as any) as Item;
        const e = game.createEvent();
        item.onDeselect(e, e.board.getCurrentCell());
        if (e.isCancelled) {
          continue;
        }
        player.bag.remove(item);
        const cell = event.board.findRandomCell();
        if (cell) {
          cell.addItem(item);
        }
      }
      events.fireModalMessage(
        "The Hooloovoo teleports your stuff all over the place",
      );
    } else {
      agent.changeHealth(DAMAGE.HOOLOOVOO);
    }
  }
  onHitByItem(event: GameEvent, itemLoc: Cell, item: Item, dir: Direction) {
    // Ammunition bounces off a Hooloovoo and becomes dangerous to the player
    if (item.is(AMMUNITION)) {
      game.shoot(event, itemLoc, this, item, dir.reverse);
      event.cancel();
    }
  }
  randomSeed(): boolean {
    return true;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 6 === 0) {
      game.agentMove(cell, this, this.#targeting);
    }
  }
  static SERIALIZER = colorSerializer("Hooloovoo", Hooloovoo);
}

export class KillerBee extends AbstractAgent implements Animated {
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Killer Bee",
      FLIER | ORGANIC,
      color,
      CTBH.KILLER_BEE,
      Sym.of("a", GOLD),
    );
    this.#targeting = new Targeting().attackPlayer(7).moveRandomly();
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.KILLER_BEE);
    // "a small sting, and they die afterward" — mirrors Game.die(attackerLoc, this)
    this.onDie(event, attackerLoc);
    attackerLoc.removeAgent(this);
    attackerLoc.addEffect(new Hit(this));
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 3 === 0) {
      game.agentMove(cell, this, this.#targeting);
    }
  }
  static SERIALIZER = colorSerializer("KillerBee", KillerBee);
}

export class LavaWorm extends AbstractAgent implements Animated {
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Lava Worm",
      LAVITIC | CARNIVORE | FIRE_RESISTANT,
      color,
      CTBH.LAVA_WORM,
      Sym.of("z", RED),
    );
    this.#targeting = new Targeting()
      .attackPlayer(8)
      .moveRandomly()
      .trackPlayer();
  }
  randomSeed(): boolean {
    return true;
  }
  canEnter(direction: Direction, from: Cell, to: Cell): boolean {
    return to.terrain !== null && to.terrain.is(LAVITIC);
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.LAVA_WORM);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 3 === 0) {
      game.agentMove(cell, this, this.#targeting);
    }
  }
  static SERIALIZER = colorSerializer("LavaWorm", LavaWorm);
}

export class LightningLizard extends AbstractAgent implements Animated {
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Lightning Lizard",
      CARNIVORE | ORGANIC,
      color,
      CTBH.LIGHTNING_LIZARD,
      Sym.of("£", ORANGE, null, DARKORANGE, null),
    );
    this.#targeting = new Targeting().attackPlayer(12).dodgeBullets(90);
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.LIGHTNING_LIZARD);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    game.agentMove(cell, this, this.#targeting);
  }
  static SERIALIZER = colorSerializer("LightningLizard", LightningLizard);
}

export class Optilisk extends AbstractAgent implements Animated {
  #movTargeting: Targeting;
  #shootTargeting: Targeting;
  constructor(color: Color) {
    super(
      "Optilisk",
      0,
      color,
      CTBH.OPTILISK,
      Sym.of("e", BLUE, null, DARKBLUE, null),
    );
    this.#movTargeting = new Targeting()
      .attackPlayer(14)
      .moveRandomly()
      .trackPlayer();
    this.#shootTargeting = new Targeting().attackPlayer(10);
  }
  randomSeed(): boolean {
    return true;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.OPTILISK);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 6 === 0) {
      // Smart enough to stop shooting when the player is already paralyzed,
      // or is holding the Mirror Shield.
      const disabled = event.board.getCurrentCell().agent instanceof Paralyzed;
      if (!disabled && game.player?.bag.getSelected().name !== "The Mirror Shield") {
        game.agentShoot(cell, this, Registry.item("Parabullet"), this.#shootTargeting);
      }
    } else if (frame % 8 === 0) {
      game.agentMove(cell, this, this.#movTargeting);
    }
  }
  static SERIALIZER = colorSerializer("Optilisk", Optilisk);
}

export class Rhindle extends AbstractAgent implements Animated {
  #targeting: Targeting;
  constructor(color: Color) {
    super(
      "Rhindle",
      CARNIVORE | ORGANIC | FIRE_RESISTANT,
      color,
      CTBH.RHINDLE,
      Sym.of("𐁙", WHITE, null, BLACK, null), // &
    );
    this.#targeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(10)
      .moveRandomly()
      .trackPlayer();
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.RHINDLE);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 5 === 0) game.agentMove(cell, this, this.#targeting);
  }
  static SERIALIZER = colorSerializer("Rhindle", Rhindle);
}

export class Sleestak extends AbstractAgent implements Animated {
  #movTargeting: Targeting;
  #shootTargeting: Targeting;
  constructor(color: Color) {
    super(
      "Sleestak",
      CARNIVORE | ORGANIC,
      color,
      CTBH.SLEESTAK,
      Sym.of("S", OLIVEDRAB, null, DARKOLIVEGREEN, null),
    );
    this.#movTargeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(12)
      .moveRandomly()
      .trackPlayer();
    this.#shootTargeting = new Targeting().attackPlayer(10);
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.SLEESTAK);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 7 === 0) {
      game.agentMove(cell, this, this.#movTargeting);
    } else if (frame % 6 === 0) {
      game.agentShoot(cell, this, Registry.item("Arrow"), this.#shootTargeting);
    }
  }
  static SERIALIZER = colorSerializer("Sleestak", Sleestak);
}

export class Tetrite extends AbstractAgent implements Animated {
  generation: number;
  #targeting: Targeting;
  constructor(generation: number) {
    const color = generation === 0 ? SEAGREEN : generation === 1 ? MEDIUMSEAGREEN : DARKSEAGREEN;
    super(
      "Tetrite",
      CARNIVORE | ORGANIC,
      NONE,
      CTBH.TETRITE,
      Sym.of("∂", color),
    );
    this.generation = generation;
    this.#targeting = new Targeting()
      .dodgeBullets(50)
      .attackPlayer(14)
      .moveRandomly();
  }
  randomSeed(): boolean {
    return true
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.TETRITE);
  }
  onDie(event: GameEvent, cell: Cell) {
    if (this.generation < 2) {
      this.#createTwoMore(cell, this.generation + 1);
    }
  }
  #createTwoMore(center: Cell, generation: number) {
    const adj = [];
    for (const dir of ADJ_DIRECTIONS) {
      const c = center.getAdjacentCell(dir);
      if (c != null && c.canEnter(center, this, dir, false)) {
        adj.push(c);
      }
    }
    // Reuse a single cached Tetrite singleton for both spawned cells.
    const child = Registry.agent(`Tetrite|${generation}`);
    let count = Math.min(2, adj.length);
    while (count > 0) {
      const idx = Math.floor(Math.random() * adj.length);
      const c = adj.splice(idx, 1)[0];
      c.setAgent(child);
      count--;
    }
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 5 === 0) {
      game.agentMove(cell, this, this.#targeting);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([gen]: string[]) {
      return new Tetrite(parseInt(gen) || 0);
    }
    store(t: Tetrite) {
      return `Tetrite|${t.generation}`;
    }
    example() {
      return new Tetrite(0);
    }
    template() {
      return "Tetrite|{generation}";
    }
    tag() {
      return "Agents";
    }
  })();
}

export class Thermadon extends AbstractAgent implements Animated {
  #movTargeting: Targeting;
  #shootTargeting: Targeting;
  constructor(color: Color) {
    super(
      "Thermadon",
      CARNIVORE | ORGANIC | FIRE_RESISTANT,
      color,
      CTBH.THERMADON,
      Sym.of("Ð", FIREBRICK),
    );
    this.#movTargeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(14)
      .moveRandomly()
      .trackPlayer();
    this.#shootTargeting = new Targeting().attackPlayer(6);
  }
  randomSeed(): boolean {
    return true;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.THERMADON);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 15 === 0) {
      game.agentShoot(cell, this, Registry.item("Fireball"), this.#shootTargeting);
    } else if (frame % 7 === 0) {
      game.agentMove(cell, this, this.#movTargeting);
    }
  }
  static SERIALIZER = colorSerializer("Thermadon", Thermadon);
}

export class Triffid extends AbstractAgent implements Animated {
  #movTargeting: Targeting;
  #dartTargeting: Targeting;
  constructor(color: Color) {
    super("A Triffid", ORGANIC, color, CTBH.TRIFFID, Sym.of("¥", DARKGREEN));
    this.#movTargeting = new Targeting().attackPlayer(20).moveRandomly();
    this.#dartTargeting = new Targeting().attackPlayer(10);
  }
  randomSeed(): boolean {
    return true;
  }
  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    agent.changeHealth(DAMAGE.TRIFFID);
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 25 === 0) {
      game.agentMove(cell, this, this.#movTargeting);
    } else if (frame % 8 === 0) {
      game.agentShoot(cell, this, Registry.item("PoisonDart"), this.#dartTargeting);
    }
  }
  static SERIALIZER = colorSerializer("Triffid", Triffid);
}

export class Tumbleweed extends AbstractAgent implements Animated {
  direction: Direction;
  #targeting: Targeting;
  constructor(direction: Direction) {
    super(
      "Tumbleweed",
      ORGANIC | PENETRABLE | PUSHABLE,
      NONE,
      INDESTRUCTIBLE,
      Sym.of("*", SADDLEBROWN),
    );
    this.direction = direction;
    this.#targeting = new Targeting();
  }
  randomSeed(): boolean {
    return true;
  }
  changeHealth() {
    return 100;
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    // Rather than a blind push, deflect along the best available direction
    // near the point of contact (mirrors Java's Tumbleweed.onHitBy exactly).
    const newDir = findPathInDirection(
      agentLoc.board,
      agentLoc,
      agent,
      null,
      dir,
      this.#targeting,
    );
    if (newDir != null) {
      const next = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir);
      game.agentMoveInDirection(event, next!, this, newDir);
    } else {
      event.cancel();
    }
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 20 === 0) {
      const next = cell.board.getAdjacentCell(cell.x, cell.y, this.direction);
      if (next != null) {
        const dir = findPathInDirection(
          cell.board,
          cell,
          this,
          null,
          this.direction,
          this.#targeting,
        );
        if (dir != null) {
          const event = game.createEvent();
          game.agentMoveInDirection(event, cell, this, dir);
        }
      } else {
        // At board edge: wrap to the opposite side.
        let wrapCell = TerrainUtils.getCellOnOppositeSide(cell, this.direction);
        if (wrapCell && wrapCell.canEnter(cell, this, this.direction, false)) {
          cell.removeAgent(this);
          wrapCell.setAgent(this);
          return;
        }
        // Try a lateral path then wrap from that position.
        const dir = findPathInDirection(
          cell.board,
          cell,
          this,
          null,
          this.direction,
          this.#targeting,
        );
        if (dir != null) {
          const lateralCell = cell.board.getAdjacentCell(cell.x, cell.y, dir);
          if (lateralCell) {
            wrapCell = TerrainUtils.getCellOnOppositeSide(
              lateralCell,
              this.direction,
            );
            if (
              wrapCell &&
              wrapCell.canEnter(cell, this, this.direction, false)
            ) {
              cell.removeAgent(this);
              wrapCell.setAgent(this);
              return;
            }
          }
        }
        // Last resort: remove the tumbleweed from the board.
        cell.removeAgent(this);
      }
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    // TODO: most of thes should be string[] not any[]
    create([dir]: string[]) {
      return new Tumbleweed(directionByName(dir) ?? WEST);
    }
    store(t: Tumbleweed) {
      return `Tumbleweed|${t.direction.name}`;
    }
    example() {
      return new Tumbleweed(WEST);
    }
    template() {
      return "Tumbleweed|{direction}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Statue — an indestructible agent-proxy that wraps another agent.
 * On a color event it transforms back into the wrapped agent.
 * Visually it appears stone-grey; it cannot be destroyed.
 */
export class Statue extends AgentProxy implements Animated {
  constructor(agent: Agent, color: Color) {
    super(agent, 0);
    this.name = `${agent.name} Statue`;
    this.color = color;
    this.symbol = Sym.of(
      agent.symbol.entity,
      LESSNEARBLACK,
      null,
      BUILDING_WALL,
      null,
    );
  }
  randomSeed(): boolean {
    return false;
  }
  changeHealth(v: number) {
    return 100;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (this.is(PLAYER)) {
      super.onFrame(event, cell, frame);
    }
  }
  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    event.cancel();
  }
  onColorEvent(event: GameEvent, color: Color, cell: Cell) {
    if (color == this.color && this.agent) {
      cell.setAgent(this.agent);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      const agent = Registry.agent(args[0]);
      const color = colorByName(args[1]) ?? NONE;
      return new Statue(agent, color);
    }
    store(s: Statue) {
      return `Statue|${this.esc(s.agent)}|${s.color.name}`;
    }
    example() {
      return new Statue(new Triffid(NONE), NONE);
    }
    template() {
      return "Statue|{agent}|{color}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * An AgentProxy decorator that renders the underlying agent paralyzed for
 * 40 animation frames, then restores the original agent.
 *
 * Visually the wrapped agent's symbol is tinted with a DARKVIOLET background.
 * `onFrame` is suppressed by AgentProxy for the duration. If wrapping the player,
 * the PARALYZED flag is removed on restoration.
 */
export class Paralyzed extends AgentProxy implements Animated {
  constructor(agent: Agent) {
    super(agent, NOT_EDITABLE);
    // Paralyzed symbol: same glyph and color, DARKVIOLET background
    const sym = agent.symbol;
    this.symbol = Sym.of(
      sym.entity,
      sym.getColor(false) as Color,
      DARKVIOLET,
      sym.getColor(true) as Color,
      DARKVIOLET,
    );
  }
  randomSeed() {
    return false;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame <= 40) {
      // Keep the wrapped player's animations running during paralysis.
      if (this.is(PLAYER) && isAnimated(this.agent)) {
        this.agent.onFrame(event, cell, frame);
      }
      return;
    }
    // After 40 frames restore the original agent.
    if (this.is(PLAYER)) {
      event.player.remove(PARALYZED);
    }
    cell.setAgent(this.agent);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([agentArg]: string[]) {
      const agent =
        typeof agentArg === "string" ? Registry.agent(agentArg) : agentArg;
      return new Paralyzed(agent);
    }
    store(p: Paralyzed) {
      return `Paralyzed|${this.esc(p.agent)}`;
    }
    example() {
      return new Paralyzed(new Triffid(NONE));
    }
    template() {
      return "Paralyzed|{agent}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export function registerCreatures() {
  Registry.register("Boulder", Boulder.SERIALIZER);
  Registry.register("RollingBoulder", RollingBoulder.SERIALIZER);
  Registry.register("Pusher", Pusher.SERIALIZER);
  Registry.register("Slider", Slider.SERIALIZER);
  Registry.register("Campfire", Campfire.SERIALIZER);
  Registry.register("Pillar", Pillar.SERIALIZER);
  Registry.register("Asciiroth", Asciiroth.SERIALIZER);
  Registry.register("Cephalid", Cephalid.SERIALIZER);
  Registry.register("Corvid", Corvid.SERIALIZER);
  Registry.register("Farthapod", Farthapod.SERIALIZER);
  Registry.register("GreatOldOne", GreatOldOne.SERIALIZER);
  Registry.register("Hooloovoo", Hooloovoo.SERIALIZER);
  Registry.register("KillerBee", KillerBee.SERIALIZER);
  Registry.register("LavaWorm", LavaWorm.SERIALIZER);
  Registry.register("LightningLizard", LightningLizard.SERIALIZER);
  Registry.register("Optilisk", Optilisk.SERIALIZER);
  Registry.register("Rhindle", Rhindle.SERIALIZER);
  Registry.register("Sleestak", Sleestak.SERIALIZER);
  Registry.register("Thermadon", Thermadon.SERIALIZER);
  Registry.register("Triffid", Triffid.SERIALIZER);
  Registry.register("Tetrite", Tetrite.SERIALIZER);
  Registry.register("Tumbleweed", Tumbleweed.SERIALIZER);
  Registry.register("Statue", Statue.SERIALIZER);
  Registry.register("Paralyzed", Paralyzed.SERIALIZER);
}