pieces/agents/creatures.js

import { Agent } from "../../core/agent.js";
import { AgentProxy } from "../../core/agent-proxy.js";
import { Registry } from "../../core/registry.js";
import { game } from "../../core/game.js";
import { events } from "../../core/events.js";
import { EMPTY_HANDED } from "../../core/player.js";
import { Targeting, findPathInDirection } from "./targeting.js";
import { TerrainUtils } from "../../core/terrain-utils.js";
import {
  ORGANIC,
  PENETRABLE,
  ETHEREAL,
  FLIER,
  CARNIVORE,
  PUSHABLE,
  FIRE_RESISTANT,
  LAVITIC,
  AQUATIC,
  PLAYER,
  WEAK,
  NOT_EDITABLE,
  PARALYZED,
  THEFT_RESISTANT,
  AMMUNITION,
} from "../../core/flags.js";
import {
  NONE,
  BROWN,
  DARKGOLDENROD,
  RED,
  ORANGE,
  DARKORANGE,
  ORCHID,
  DARKORCHID,
  SILVER,
  DARKSLATEBLUE,
  POWDERBLUE,
  MIDNIGHTBLUE,
  GOLD,
  SLATEBLUE,
  FIREBRICK,
  OLIVEDRAB,
  DARKOLIVEGREEN,
  DARKGREEN,
  SEAGREEN,
  MEDIUMSEAGREEN,
  DARKSEAGREEN,
  BLUE,
  DARKBLUE,
  WHITE,
  BLACK,
  SADDLEBROWN,
  LIGHTSTEELBLUE,
  VIOLET,
  MAROON,
  LESSNEARBLACK,
  BUILDING_WALL,
  BUILDING_FLOOR,
  DARKVIOLET,
  CYAN,
  SALMON as SALMONCOLOR,
  colorByName,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { stateFromString } from "../../core/state.js";
import {
  directionByName,
  ADJ_DIRECTIONS,
  NORTH,
  SOUTH,
  EAST,
  WEST,
} from "../../core/direction.js";

// ── Serializer helpers ───────────────────────────────────────────────────────

function makeColoredSerializer(typeId, Cls) {
  return new (class extends BaseSerializer {
    create([color]) {
      return new Cls(colorByName(color) ?? NONE);
    }
    store(a) {
      return `${typeId}|${a.color.name}`;
    }
    example() {
      return new Cls(NONE);
    }
    template(_id) {
      return `${typeId}|{color}`;
    }
    tag() {
      return "Agents";
    }
  })();
}

// ── Combat stats (mirrors CombatStats.java) ───────────────────────────────────

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

// ── AbstractAgent base ────────────────────────────────────────────────────────

/**
 * 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 {
  constructor(name, flags, color, chanceToHit, symbol) {
    super(name, flags, color, symbol);
    this._chanceToHit = chanceToHit;
  }

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

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

  onHitBy(event, agentLoc, agent, dir) {
    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) ??
          agentLoc;
        game.agentMoveInDirection(event, myCell, this, dir);
      }
    } else if (
      agent instanceof RollingBoulder &&
      !(this instanceof ImmobileAgent)
    ) {
      const myCell =
        agentLoc.board?.getAdjacentCell(agentLoc.x, agentLoc.y, dir) ??
        agentLoc;
      game.agentMoveInDirection(event, myCell, this, dir);
      if (event.isCancelled) {
        event.board?.fireColorEvent?.(event, this.color, agentLoc);
      }
    } else {
      event.cancel();
    }
  }
}

// ── ImmobileAgent base ────────────────────────────────────────────────────────

/** An agent that cannot be moved or destroyed. */
export class ImmobileAgent extends AbstractAgent {
  constructor(name, flags, symbol) {
    super(name, flags, null, INDESTRUCTIBLE, symbol);
  }
  changeHealth(_value) {
    return 100;
  }
}

// ── Static / puzzle agents ────────────────────────────────────────────────────

export class AbstractBoulder extends AbstractAgent {
  constructor(name, flags, color, chanceToHit, symbol) {
    super(name, flags, color, chanceToHit, symbol);
  }
}

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

export class RollingBoulder extends AbstractBoulder {
  constructor(direction, color, state) {
    super("Rolling Boulder", 0, color, INDESTRUCTIBLE, Symbol.of("O", BROWN));
    this._direction = direction;
    this._state = state;
  }
  onHit(_event, _boulderCell, playerCell, 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, cell, frame) {
    if (this._state.isOn() && frame % 5 === 0) {
      const event = game.createEvent();
      game.agentMoveInDirection(event, cell, this, this._direction);
    }
  }
  onColorEvent(_event, color, cell) {
    if (color === this.color && this._state.isOff()) {
      cell.removeAgent(this);
      const next = Registry.get(
        `RollingBoulder|${this._direction.name}|${this.color.name}|on`,
      );
      cell.setAgent(next);
    }
  }
  changeHealth(_v) {
    return 100;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir, color, state]) {
      return new RollingBoulder(
        directionByName(dir),
        colorByName(color) ?? NONE,
        stateFromString(state),
      );
    }
    store(b) {
      return `RollingBoulder|${b._direction.name}|${b.color.name}|${b._state}`;
    }
    example() {
      return new RollingBoulder(EAST, NONE, stateFromString("off"));
    }
    template(_id) {
      return "RollingBoulder|{direction}|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Pusher extends AbstractAgent {
  constructor(direction, color, state) {
    const glyph =
      direction === NORTH
        ? "\u25B2"
        : direction === SOUTH
          ? "\u25BC"
          : direction === EAST
            ? "\u25BA"
            : direction === WEST
              ? "\u25C4"
              : "?";
    super(
      "Pusher",
      0,
      color,
      INDESTRUCTIBLE,
      Symbol.of(glyph, LIGHTSTEELBLUE, null, MIDNIGHTBLUE, null),
    );
    this.isPusher = true;
    this._direction = direction;
    this._state = state;
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.PUSHER);
  }
  onFrame(_event, cell, frame) {
    if (this._state.isOn() && frame % 6 === 0) {
      const event = game.createEvent();
      game.agentMoveInDirection(event, cell, this, this._direction);
    }
  }
  onColorEvent(_event, color, cell) {
    if (color === this.color && this._state.isOff()) {
      cell.removeAgent(this);
      const next = Registry.get(
        `Pusher|${this._direction.name}|${this.color.name}|on`,
      );
      cell.setAgent(next);
    }
  }
  changeHealth(_v) {
    return 100;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir, color, state]) {
      return new Pusher(
        directionByName(dir),
        colorByName(color) ?? NONE,
        stateFromString(state),
      );
    }
    store(p) {
      return `Pusher|${p._direction.name}|${p.color.name}|${p._state}`;
    }
    example() {
      return new Pusher(NORTH, NONE, stateFromString("off"));
    }
    template(_id) {
      return "Pusher|{direction}|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Slider extends AbstractAgent {
  constructor(direction) {
    const glyph = direction === NORTH ? "↕" : "↔";
    super(
      "Slider",
      PUSHABLE,
      NONE,
      INDESTRUCTIBLE,
      Symbol.of(glyph, VIOLET, null, MAROON, null),
    );
    this.isSlider = true;
    this._direction = direction;
  }
  changeHealth(_v) {
    return 100;
  }
  onHitBy(event, agentLoc, agent, dir) {
    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) ??
        agentLoc;
      game.agentMoveInDirection(event, myCell, this, dir);
    } else {
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir]) {
      return new Slider(directionByName(dir));
    }
    store(s) {
      return `Slider|${s._direction.name}`;
    }
    example() {
      return new Slider(NORTH);
    }
    template(_id) {
      return "Slider|{direction}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Campfire extends AbstractAgent {
  constructor() {
    super(
      "Campfire",
      PENETRABLE | ETHEREAL,
      NONE,
      INDESTRUCTIBLE,
      Symbol.of("ω", RED),
    );
  }
  changeHealth(_v) {
    return 100;
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Campfire");
    }
    create(_args) {
      return new Campfire();
    }
    tag() {
      return "Room Features";
    }
  })();
}

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

// ── Animated creatures ────────────────────────────────────────────────────────

export class Asciiroth extends AbstractAgent {
  constructor(color) {
    super("Asciiroth", 0, color, CTBH.ASCIIROTH, Symbol.of("א", CYAN));
    this._movTargeting = new Targeting()
      .attackPlayer(25)
      .keepDistance(6)
      .moveRandomly();
    this._fireTargeting = new Targeting().attackPlayer(20);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.ASCIIROTH);
  }
  onFrame(_event, cell, frame) {
    if (frame % 4 === 0) {
      game.agentShoot(
        cell,
        this,
        Registry.get("Fireball"),
        this._fireTargeting,
      );
    } else if (frame % 7 === 0) {
      game.agentMove(cell, this, this._movTargeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("Asciiroth", Asciiroth);
}

export class Cephalid extends AbstractAgent {
  constructor(color) {
    super(
      "Cephalid",
      ORGANIC,
      color,
      CTBH.CEPHALID,
      Symbol.of("€", ORCHID, null, DARKORCHID, null),
    );
    this._movTargeting = new Targeting()
      .attackPlayer(14)
      .moveRandomly()
      .trackPlayer();
    this._shootTargeting = new Targeting().attackPlayer(14);
  }
  onFrame(_event, cell, frame) {
    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.get("Stoneray"),
          this._shootTargeting,
        );
      }
    }
  }
  static SERIALIZER = makeColoredSerializer("Cephalid", Cephalid);
}

export class Corvid extends AbstractAgent {
  static SYMBOLS = [
    Symbol.of("∧", POWDERBLUE, null, MIDNIGHTBLUE, null),
    Symbol.of("∨", POWDERBLUE, null, MIDNIGHTBLUE, null),
  ];
  static _stealing = new Targeting()
    .attackPlayer(12)
    .moveRandomly()
    .dodgeBullets(60);
  static _running = new Targeting().fleePlayer(12).dodgeBullets(60);

  constructor(color, item = null) {
    super("Corvid", FLIER | ORGANIC, color, CTBH.CORVID, Corvid.SYMBOLS[0]);
    this._item = item;
  }
  onHit(event, attackerLoc, _agentLoc, _agent) {
    const player = event.player;
    const held = player.bag.getSelected();
    if (held !== EMPTY_HANDED) {
      if (player.testResistance(THEFT_RESISTANT)) return;
      const e = game.createEvent();
      held.onDeselect(e, e.board?.getCurrentCell?.() ?? attackerLoc);
      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, _agentLoc, _agent, _dir) {
    event.cancel();
  }
  onDie(event, cell) {
    if (this._item) cell.addItem(this._item);
    super.onDie(event, cell);
  }
  onFrame(_event, cell, frame) {
    this.symbol = 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]) {
      const item = itemArg
        ? typeof itemArg === "string"
          ? Registry.get(itemArg)
          : itemArg
        : null;
      return new Corvid(colorByName(color) ?? NONE, item);
    }
    store(c) {
      return c._item
        ? `Corvid|${c.color.name}|${this.esc(c._item)}`
        : `Corvid|${c.color.name}`;
    }
    example() {
      return new Corvid(NONE);
    }
    template(_id) {
      return "Corvid|{color}|{item?}";
    }
    tag() {
      return "Agents";
    }
  })();
}

export class Farthapod extends AbstractAgent {
  constructor(color) {
    super(
      "Farthapod",
      CARNIVORE | ORGANIC,
      color,
      CTBH.FARTHAPOD,
      Symbol.of("¤", SILVER, null, DARKSLATEBLUE, null),
    );
    this._targeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(7)
      .moveRandomly()
      .trackPlayer();
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.FARTHAPOD);
  }
  onFrame(_event, cell, frame) {
    if (frame % 5 === 0) {
      game.agentMove(cell, this, this._targeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("Farthapod", Farthapod);
}

export class GreatOldOne extends AbstractAgent {
  constructor(color) {
    super(
      "Great Old One",
      0,
      color,
      INDESTRUCTIBLE,
      Symbol.of("ξ", WHITE, null, BLACK, null),
    );
    this._targeting = new Targeting()
      .attackPlayer(12)
      .moveRandomly()
      .trackPlayer();
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.GREAT_OLD_ONE);
  }
  onFrame(_event, cell, frame) {
    if (frame % 5 === 0) {
      game.agentMove(cell, this, this._targeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("GreatOldOne", GreatOldOne);
}

export class Hooloovoo extends AbstractAgent {
  constructor() {
    super(
      "Hooloovoo",
      ORGANIC,
      null,
      CTBH.HOOLOOVOO,
      Symbol.of("H", SLATEBLUE, DARKSLATEBLUE),
    );
    this._targeting = new Targeting().attackPlayer(8).moveRandomly();
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    const player = event.player;
    if (player.bag.size() > 1) {
      if (player.testResistance(THEFT_RESISTANT)) {
        return;
      }
      while (player.bag.size() > 1) {
        const item = player.bag.last();
        const e = game.createEvent();
        item.onDeselect(e, e.board?.getCurrentCell() ?? agentLoc);
        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, itemLoc, item, dir) {
    // 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();
    }
  }
  onFrame(_event, cell, frame) {
    if (frame % 6 === 0) {
      game.agentMove(cell, this, this._targeting);
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Hooloovoo");
    }
    create(_args) {
      return new Hooloovoo();
    }
    tag() {
      return "Agents";
    }
  })();
}

export class KillerBee extends AbstractAgent {
  constructor(color) {
    super(
      "Killer Bee",
      FLIER | ORGANIC,
      color,
      CTBH.KILLER_BEE,
      Symbol.of("a", GOLD),
    );
    this._targeting = new Targeting().attackPlayer(7).moveRandomly();
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.KILLER_BEE);
  }
  onHitBy(event, _agentLoc, _agent, _dir) {
    event.cancel();
  }
  onFrame(_event, cell, frame) {
    if (frame % 3 === 0) {
      game.agentMove(cell, this, this._targeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("KillerBee", KillerBee);
}

export class LavaWorm extends AbstractAgent {
  constructor(color) {
    super(
      "Lava Worm",
      LAVITIC | CARNIVORE | FIRE_RESISTANT,
      color,
      CTBH.LAVA_WORM,
      Symbol.of("z", RED),
    );
    this._targeting = new Targeting()
      .attackPlayer(8)
      .moveRandomly()
      .trackPlayer();
  }
  canEnter(_direction, _from, to) {
    return to.terrain.is(LAVITIC);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.LAVA_WORM);
  }
  onFrame(_event, cell, frame) {
    if (frame % 3 === 0) {
      game.agentMove(cell, this, this._targeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("LavaWorm", LavaWorm);
}

export class LightningLizard extends AbstractAgent {
  constructor(color) {
    super(
      "Lightning Lizard",
      CARNIVORE | ORGANIC,
      color,
      CTBH.LIGHTNING_LIZARD,
      Symbol.of("£", ORANGE, null, DARKORANGE, null),
    );
    this._targeting = new Targeting().attackPlayer(12).dodgeBullets(90);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.LIGHTNING_LIZARD);
  }
  onFrame(_event, cell, _frame) {
    game.agentMove(cell, this, this._targeting);
  }
  static SERIALIZER = makeColoredSerializer("LightningLizard", LightningLizard);
}

export class Optilisk extends AbstractAgent {
  constructor(color) {
    super(
      "Optilisk",
      0,
      color,
      CTBH.OPTILISK,
      Symbol.of("e", BLUE, null, DARKBLUE, null),
    );
    this._movTargeting = new Targeting()
      .attackPlayer(14)
      .moveRandomly()
      .trackPlayer();
    this._shootTargeting = new Targeting().attackPlayer(10);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.OPTILISK);
  }
  onFrame(_event, cell, frame) {
    if (frame % 6 === 0) {
      if (game.player?.bag?.getSelected()?.name !== "The Mirror Shield") {
        game.agentShoot(
          cell,
          this,
          Registry.get("Parabullet"),
          this._shootTargeting,
        );
      }
    } else if (frame % 8 === 0) {
      game.agentMove(cell, this, this._movTargeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("Optilisk", Optilisk);
}

export class Rhindle extends AbstractAgent {
  constructor(color) {
    super(
      "Rhindle",
      CARNIVORE | ORGANIC | FIRE_RESISTANT,
      color,
      CTBH.RHINDLE,
      Symbol.of("&", WHITE, null, BLACK, null),
    );
    this._targeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(10)
      .moveRandomly()
      .trackPlayer();
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.RHINDLE);
  }
  onFrame(_event, cell, frame) {
    if (frame % 5 === 0) game.agentMove(cell, this, this._targeting);
  }
  static SERIALIZER = makeColoredSerializer("Rhindle", Rhindle);
}

export class Sleestak extends AbstractAgent {
  constructor(color) {
    super(
      "Sleestak",
      CARNIVORE | ORGANIC,
      color,
      CTBH.SLEESTAK,
      Symbol.of("S", OLIVEDRAB, null, DARKOLIVEGREEN, null),
    );
    this._movTargeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(12)
      .moveRandomly()
      .trackPlayer();
    this._shootTargeting = new Targeting().attackPlayer(10);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.SLEESTAK);
  }
  onFrame(_event, cell, frame) {
    if (frame % 7 === 0) {
      game.agentMove(cell, this, this._movTargeting);
    } else if (frame % 6 === 0) {
      game.agentShoot(cell, this, Registry.get("Arrow"), this._shootTargeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("Sleestak", Sleestak);
}

export class Tetrite extends AbstractAgent {
  constructor(generation) {
    const g = generation ?? 0;
    const color = g === 0 ? SEAGREEN : g === 1 ? MEDIUMSEAGREEN : DARKSEAGREEN;
    super(
      "Tetrite",
      CARNIVORE | ORGANIC,
      NONE,
      CTBH.TETRITE,
      Symbol.of("∂", color),
    );
    this._generation = g;
    this._targeting = new Targeting()
      .dodgeBullets(50)
      .attackPlayer(14)
      .moveRandomly();
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.TETRITE);
  }
  onDie(_event, cell) {
    if (this._generation < 2) {
      this._createTwoMore(cell, new Tetrite(this._generation + 1));
    }
  }
  _createTwoMore(center, child) {
    const adj = [];
    for (const dir of ADJ_DIRECTIONS) {
      const c = center.getAdjacentCell(dir);
      if (c != null && c.canEnter(center, child, dir, false)) {
        adj.push(c);
      }
    }
    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(new Tetrite(child._generation));
      count--;
    }
  }
  onFrame(_event, cell, frame) {
    if (frame % 5 === 0) {
      game.agentMove(cell, this, this._targeting);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([gen]) {
      return new Tetrite(parseInt(gen) || 0);
    }
    store(t) {
      return `Tetrite|${t._generation}`;
    }
    example() {
      return new Tetrite(0);
    }
    template(_id) {
      return "Tetrite|{generation}";
    }
    tag() {
      return "Agents";
    }
  })();
}

export class Thermadon extends AbstractAgent {
  constructor(color) {
    super(
      "Thermadon",
      CARNIVORE | ORGANIC | FIRE_RESISTANT,
      color,
      CTBH.THERMADON,
      Symbol.of("Ð", FIREBRICK),
    );
    this._movTargeting = new Targeting()
      .dodgeBullets(90)
      .attackPlayer(14)
      .moveRandomly()
      .trackPlayer();
    this._shootTargeting = new Targeting().attackPlayer(6);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.THERMADON);
  }
  onFrame(_event, cell, frame) {
    if (frame % 15 === 0) {
      game.agentShoot(
        cell,
        this,
        Registry.get("Fireball"),
        this._shootTargeting,
      );
    } else if (frame % 7 === 0) {
      game.agentMove(cell, this, this._movTargeting);
    }
  }
  static SERIALIZER = makeColoredSerializer("Thermadon", Thermadon);
}

export class Triffid extends AbstractAgent {
  constructor(color) {
    super("A Triffid", ORGANIC, color, CTBH.TRIFFID, Symbol.of("¥", DARKGREEN));
    this._movTargeting = new Targeting().attackPlayer(20).moveRandomly();
    this._dartTargeting = new Targeting().attackPlayer(10);
  }
  onHit(event, _attackerLoc, agentLoc, agent) {
    agent.changeHealth(DAMAGE.TRIFFID);
  }
  onFrame(_event, cell, frame) {
    if (frame % 25 === 0) {
      game.agentMove(cell, this, this._movTargeting);
    } else if (frame % 8 === 0) {
      game.agentShoot(
        cell,
        this,
        Registry.get("PoisonDart"),
        this._dartTargeting,
      );
    }
  }
  static SERIALIZER = makeColoredSerializer("Triffid", Triffid);
}

export class Tumbleweed extends AbstractAgent {
  constructor(direction) {
    super(
      "Tumbleweed",
      ORGANIC,
      NONE,
      INDESTRUCTIBLE,
      Symbol.of("*", SADDLEBROWN),
    );
    if (!direction) {
      throw new Error("Tumbleweed needs a direction");
    }
    this.direction = direction;
    this._targeting = new Targeting();
  }
  changeHealth(_v) {
    return 100;
  }
  onHitBy(event, agentLoc, agent, dir) {
    const newDir = findPathInDirection(
      agentLoc.board,
      agentLoc,
      agent,
      null,
      dir,
      this._targeting,
    );
    if (newDir != null) {
      const myCell = agentLoc.board.getAdjacentCell(
        agentLoc.x,
        agentLoc.y,
        dir,
      );
      game.agentMoveInDirection(event, myCell, this, newDir);
    } else {
      event.cancel();
    }
  }
  onFrame(_event, cell, frame) {
    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 {
    create([dir]) {
      return new Tumbleweed(directionByName(dir));
    }
    store(t) {
      return `Tumbleweed|${t.direction.name}`;
    }
    example() {
      return new Tumbleweed(WEST);
    }
    template(_id) {
      return "Tumbleweed|{direction}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

// ── Statue ────────────────────────────────────────────────────────────────────

/**
 * 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 killed.
 */
export class Statue extends AgentProxy {
  constructor(agent, color) {
    super(agent, 0);
    this.name = `${agent.name} Statue`;
    this.color = color;
    this.symbol = Symbol.of(
      agent.symbol.entity ?? "\u2503",
      LESSNEARBLACK,
      null,
      BUILDING_WALL,
      null,
    );
  }
  changeHealth(_v) {
    return 100;
  }
  onFrame(event, cell, frame) {
    if (this.is(PLAYER)) {
      super.onFrame(event, cell, frame);
    }
  }
  onHitBy(event, _agentLoc, _agent, _dir) {
    event.cancel();
  }
  onColorEvent(_event, color, cell) {
    if (color == this.color && this.agent) {
      cell.setAgent(this.agent);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      const agent =
        typeof args[0] === "string" ? _tryGetAgent(args[0]) : args[0];
      const color = colorByName(args[1]) ?? NONE;
      return new Statue(agent, color);
    }
    store(s) {
      return `Statue|${this.esc(s.agent)}|${s.color.name}`;
    }
    example() {
      return new Statue(new Boulder(), NONE);
    }
    template(_id) {
      return "Statue|{agent}|{color}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Paralyzed ─────────────────────────────────────────────────────────────────

/**
 * 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.
 * AI (`onTurn`) is suppressed by AgentProxy for the duration.
 * If wrapping the player, the PARALYZED flag is removed on restoration.
 *
 * Mirrors Java's `Paralyzed` class.
 */
export class Paralyzed extends AgentProxy {
  constructor(agent) {
    super(agent, NOT_EDITABLE);
    // Paralyzed symbol: same glyph and color, DARKVIOLET background
    const sym = agent.symbol;
    this.symbol = Symbol.of(
      sym.entity,
      sym.color,
      DARKVIOLET,
      sym.outsideColor,
      DARKVIOLET,
    );
  }
  onFrame(event, cell, frame) {
    if (frame <= 40) {
      // Keep the wrapped player's animations running during paralysis.
      if (this.is(PLAYER) && this.agent.onFrame) {
        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]) {
      const agent =
        typeof agentArg === "string" ? _tryGetAgent(agentArg) : agentArg;
      return new Paralyzed(agent);
    }
    store(p) {
      return `Paralyzed|${this.esc(p.agent)}`;
    }
    example() {
      return new Paralyzed(new Boulder());
    }
    template(_id) {
      return "Paralyzed|{agent}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Salmon ────────────────────────────────────────────────────────────────────

/** Salmon — passive organic aquatic creature; lives in water. */
export class Salmon extends AbstractAgent {
  constructor() {
    super(
      "Salmon",
      ORGANIC | AQUATIC,
      SALMONCOLOR,
      0,
      Symbol.of("α", SALMONCOLOR),
    );
  }
  canEnter(_dir, _from, to) {
    return to.terrain.is(AQUATIC);
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Salmon");
    }
    create(_args) {
      return new Salmon();
    }
    tag() {
      return "Agents";
    }
  })();
}

// ── Registry ──────────────────────────────────────────────────────────────────

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);
  Registry.register("Salmon", Salmon.SERIALIZER);
}

function _tryGetAgent(key) {
  try {
    return Registry.get(key);
  } catch (_) {
    return null;
  }
}