pieces/terrain/decorators.ts

import {
  AbstractBoulder,
  Pusher,
  Slider,
} from "../agents/creatures.ts";
import { Agent } from "../../core/agent.ts";
import { Item } from "../../core/item.ts";
import { Terrain } from "../../core/terrain.ts";
import { Registry } from "../../core/registry.ts";
import { events } from "../../core/event-bus.ts";
import { Animated } from "../../core/animated.ts";
import {
  is,
  getFlag,
  description,
  DETECT_HIDDEN,
  PLAYER,
  TRAVERSABLE,
  PENETRABLE,
  POISONED,
} from "../../core/flags.ts";
import {
  WHITE,
  BLACK,
  colorByName,
  NONE,
  NEARBLACK,
  BARELY_BUILDING_WALL,
  Color,
  RED,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import { Serializer, TypeOnlySerializer, BaseSerializer } from "../../core/serializer.ts";
import { game } from "../../core/game.ts";
import { State, stateFromString } from "../../core/state.ts";
import { Cell } from "../../core/cell.ts";
import { Direction } from "../../core/direction.ts";
import { Player } from "../../core/player.ts";
import { GameEvent } from "../../core/game-event.ts";
import { InFlightItem, Hit, Open } from "../effects/effects.ts";
import { Floor, Wall } from "./basic.ts";
import { Crowbar } from "../items/items.ts";
import { Pit, Chest, Crate } from "./features.ts";
import { Effect } from "../../core/effect.ts";
import { Piece } from "../../core/piece.ts";

/**
 * Abstract base class for terrain decorators.
 *
 * A decorator wraps another terrain and augments its behavior. It uses the
 * template-method pattern: the base class delegates all terrain callbacks to
 * the wrapped terrain first, then calls `*Internal` hook methods for the
 * decorator subclass.
 *
 * The wrapped terrain's flags, name, and symbol are inherited unless overridden.
 */
export class Decorator extends Terrain {
  terrain: Terrain;
  constructor(terrain: Terrain, name: string, flags: number, color: Color, symbol: Sym) {
    super(name ?? terrain.name, flags, color ?? NONE, symbol ?? terrain.symbol);
    this.terrain = terrain;
  }

  // Delegate flag checks to underlying terrain. Must call is()/not() (not
  // read .flags directly) so nested decorators delegate all the way down
  // to the real underlying terrain, matching Java's Decorator.is/not.
  is(flag: number) {
    return this.terrain.is(flag);
  }
  not(flag: number) {
    return this.terrain.not(flag);
  }

  /** Return the terrain being wrapped (TerrainProxy interface). */
  getProxiedTerrain(): Terrain {
    return this.terrain;
  }

  // canEnter / canExit delegate to wrapped terrain
  canEnter(agent: Agent, cell: Cell, direction: Direction) {
    return this.terrain.canEnter(agent, cell, direction);
  }
  canExit(agent: Agent, cell: Cell, direction: Direction) {
    return this.terrain.canExit(agent, cell, direction);
  }

  onEnter(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    this.terrain.onEnter(event, player, cell, dir);
    this.onEnterInternal(event, player, cell, dir);
  }
  onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    this.terrain.onExit(event, player, cell, dir);
    this.onExitInternal(event, player, cell, dir);
  }
  onAgentEnter(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    this.terrain.onAgentEnter(event, agent, cell, dir);
    this.onAgentEnterInternal(event, agent, cell, dir);
  }
  onAgentExit(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    this.terrain.onAgentExit(event, agent, cell, dir);
    this.onAgentExitInternal(event, agent, cell, dir);
  }
  onFlyOver(event: GameEvent, cell: Cell, flier: InFlightItem) {
    this.terrain.onFlyOver(event, cell, flier);
    this.onFlyOverInternal(event, cell, flier);
  }
  onDrop(event: GameEvent, cell: Cell, item: Item) {
    this.terrain.onDrop(event, cell, item);
    this.onDropInternal(event, cell, item);
  }
  onPickup(event: GameEvent, loc: Cell, agent: Agent, item: Item) {
    this.terrain.onPickup(event, loc, agent, item);
    this.onPickupInternal(event, loc, agent, item);
  }
  onAdjacentTo(event: GameEvent, cell: Cell) {
    this.terrain.onAdjacentTo(event, cell);
    this.onAdjacentToInternal(event, cell);
  }
  onNotAdjacentTo(event: GameEvent, cell: Cell) {
    this.terrain.onNotAdjacentTo(event, cell);
    this.onNotAdjacentToInternal(event, cell);
  }
  onColorEvent(event: GameEvent, color: Color, cell: Cell) {
    // Forward to wrapped terrain if it handles color events
    this.terrain.onColorEvent?.(event, color, cell);
    this.onColorEventInternal(event, color, cell);
  }
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) { }
  onExitInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) { }
  onAgentEnterInternal(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) { }
  onAgentExitInternal(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) { }
  onFlyOverInternal(event: GameEvent, cell: Cell, flier: InFlightItem) { }
  onDropInternal(event: GameEvent, cell: Cell, item: Item) { }
  onPickupInternal(event: GameEvent, loc: Cell, agent: Agent, item: Item) { }
  onAdjacentToInternal(event: GameEvent, cell: Cell) { }
  onNotAdjacentToInternal(event: GameEvent, cell: Cell) { }
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) { }
}

/**
 * Holds two terrains and activates one at a time based on state.
 * A color event toggles between terrain1 (ON) and terrain2 (OFF).
 */
export class DualTerrain extends Terrain {
  terrain1: Terrain;
  terrain2: Terrain;
  state: State;
  constructor(terrain1: Terrain, terrain2: Terrain, state: State, color: Color) {
    const active = state.isOn() ? terrain1 : terrain2;
    super(active.name, active.flags, color, active.symbol);
    this.terrain1 = terrain1;
    this.terrain2 = terrain2;
    this.state = state;
  }

  get #activeTerrain() {
    return this.state.isOn() ? this.terrain1 : this.terrain2;
  }

  is(flag: number) {
    return this.#activeTerrain.is(flag);
  }
  not(flag: number) {
    return this.#activeTerrain.not(flag);
  }
  getProxiedTerrain() {
    return this.#activeTerrain;
  }
  // Matches Java's DualTerrain.onColorEvent: swap terrain1/terrain2 (keeping
  // state unchanged) so the previously-inactive terrain becomes active, then
  // cache/set the new piece. Unlike TerrainUtils.toggleCellState, this does
  // NOT skip the swap when an agent occupies the cell — Java's DualTerrain
  // always flips regardless of what's standing on it.
  onColorEvent(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color) {
      const swapped = new DualTerrain(this.terrain2, this.terrain1, this.state, this.color);
      cell.setTerrain(Registry.get(DualTerrain.SERIALIZER.store(swapped)) as Terrain);
    }
  }
  canEnter(agent: Agent, cell: Cell, dir: Direction) {
    return this.#activeTerrain.canEnter(agent, cell, dir);
  }
  canExit(agent: Agent, cell: Cell, dir: Direction) {
    return this.#activeTerrain.canExit(agent, cell, dir);
  }
  onEnter(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    this.#activeTerrain.onEnter(event, player, cell, dir);
  }
  onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    this.#activeTerrain.onExit(event, player, cell, dir);
  }
  onAgentEnter(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    this.#activeTerrain.onAgentEnter(event, agent, cell, dir);
  }
  onAgentExit(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    this.#activeTerrain.onAgentExit(event, agent, cell, dir);
  }
  onFlyOver(event: GameEvent, cell: Cell, flyer: InFlightItem) {
    this.#activeTerrain.onFlyOver(event, cell, flyer);
  }
  onDrop(event: GameEvent, cell: Cell, item: Item) {
    this.#activeTerrain.onDrop(event, cell, item);
  }
  onPickup(event: GameEvent, cell: Cell, agent: Agent, item: Item) {
    this.#activeTerrain.onPickup(event, cell, agent, item);
  }
  onAdjacentTo(event: GameEvent, cell: Cell) {
    this.#activeTerrain.onAdjacentTo(event, cell);
  }
  onNotAdjacentTo(event: GameEvent, cell: Cell) {
    this.#activeTerrain.onNotAdjacentTo(event, cell);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([t1, t2, state, color]: string[]) {
      return new DualTerrain(
        Registry.terrain(t1),
        Registry.terrain(t2),
        stateFromString(state),
        colorByName(color) ?? NONE,
      );
    }
    store(d: DualTerrain) {
      return `DualTerrain|${this.esc(d.terrain1)}|${this.esc(d.terrain2)}|${d.state.name}|${d.color.name}`;
    }
    example() {
      return new DualTerrain(Registry.terrain("Floor"), Registry.terrain("Wall"), stateFromString("on"), NONE);
    }
    template() {
      return "DualTerrain|{terrain}|{terrain}|{state}|{color}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * A sign on top of traversable terrain — shows a message when the player
 * steps onto it.
 */
export class Sign extends Decorator {
  message: string;
  constructor(terrain: Terrain, message: string) {
    super(
      terrain,
      "Sign",
      terrain.flags,
      NONE,
      Sym.of(
        "⌂",
        WHITE,
        terrain.symbol.getBackground(false),
        BLACK,
        terrain.symbol.getBackground(true),
      ),
    );
    this.message = message;
  }
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    events.fireMessage(this.message, cell);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, message]: string[]) {
      return new Sign(Registry.terrain(terrain), message);
    }
    store(s: Sign) {
      return `Sign|${this.esc(s.terrain)}|${s.message}`;
    }
    example() {
      return new Sign(Registry.terrain("Floor"), "Hello!");
    }
    template() {
      return "Sign|{terrain}|{message}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

export class Rubble extends Decorator {
  constructor(terrain: Terrain) {
    super(
      terrain,
      "Rubble",
      TRAVERSABLE | PENETRABLE,
      NONE,
      Sym.of(
        "∴",
        BARELY_BUILDING_WALL,
        terrain.symbol.getBackground(false),
        BARELY_BUILDING_WALL,
        terrain.symbol.getBackground(true),
      ),
    );
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]: string[]) {
      return new Rubble(Registry.terrain(terrain));
    }
    example() {
      return new Rubble(Registry.terrain("Floor"));
    }
    store(s: Rubble) {
      return `Rubble|${this.esc(s.terrain)}`;
    }
    template() {
      return "Rubble|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Sets a flag on the player when a matching color event is received.
 *
 * Typical use: mark the player as having completed a task or entered a
 * region — e.g. set the "poisoned" flag when the player steps on a
 * trap that fires a color event.
 *
 * @extends Decorator
 */
export class Flagger extends Decorator {
  flag: number;
  constructor(terrain: Terrain, color: Color, flag: number) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    if (flag === -1) {
      throw new Error("Flag is not valid");
    }
    this.flag = flag;
  }
  /**
   * Adds {@link Flagger#flag} to the player when the received color matches
   * {@link Flagger#color}. Skips silently when there is no player in the event.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && event.player) {
      event.player.add(this.flag);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, flagStr]: string[]) {
      return new Flagger(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        getFlag(flagStr),
      );
    }
    store(f: Flagger) {
      return `Flagger|${this.esc(f.terrain)}|${f.color.name}|${description(f.flag)}`;
    }
    example() {
      return new Flagger(Registry.terrain("Floor"), NONE, POISONED);
    }
    template() {
      return "Flagger|{terrain}|{color}|{flag}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Clears a flag from the player when a matching color event is received.
 *
 * Typical use: cure a status effect or reset a condition — e.g. remove
 * the "poisoned" flag when the player drinks from a fountain that fires
 * a color event.
 *
 * @extends Decorator
 */
export class Unflagger extends Decorator {
  flag: number;
  constructor(terrain: Terrain, color: Color, flag: number) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    if (flag === -1) {
      throw new Error("Flag is not valid");
    }
    this.flag = flag;
  }
  /**
   * Removes {@link Unflagger#flag} from the player when the received color
   * matches {@link Unflagger#color}. Skips silently when there is no player
   * in the event.
   *
   * @param {GameEvent} event
   * @param {Color}     color
   * @param {Cell}      _cell - Unused.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && event.player) {
      event.player.remove(this.flag);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, flagStr]: string[]) {
      return new Unflagger(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        getFlag(flagStr),
      );
    }
    store(f: Unflagger) {
      return `Unflagger|${this.esc(f.terrain)}|${f.color.name}|${description(f.flag)}`;
    }
    example() {
      return new Unflagger(Registry.terrain("Floor"), NONE, POISONED);
    }
    template() {
      return "Unflagger|{terrain}|{color}|{flag}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Displays a modal message to the player when a matching color event is
 * received.
 *
 * Typical use: deliver narrative text or instructions at a scripted moment
 * — e.g. show a warning when the player triggers a trap, or present lore
 * when a lever is pulled elsewhere on the board.
 *
 * @extends Decorator
 */
export class Messenger extends Decorator {
  message: string;
  constructor(terrain: Terrain, color: Color, message: string) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.message = message;
  }
  /**
   * Shows the modal message when the received color matches
   * {@link Messenger#color}.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color) {
      events.fireModalMessage(this.message);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, message]: string[]) {
      return new Messenger(Registry.terrain(terrain), colorByName(color) ?? NONE, message);
    }
    store(m: Messenger) {
      return `Messenger|${this.esc(m.terrain)}|${m.color.name}|${m.message}`;
    }
    example() {
      return new Messenger(Registry.terrain("Floor"), NONE, "Hello!");
    }
    template() {
      return "Messenger|{terrain}|{color}|{message}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Adds a specific item to the player's bag when a matching color event is
 * received.
 *
 * Typical use: grant the player an item as a reward or story beat — e.g.
 * place a Key in the player's bag when a puzzle is solved elsewhere on the
 * board.
 *
 * @extends Decorator
 */
export class Equipper extends Decorator {
  item: Item;
  constructor(terrain: Terrain, color: Color, item: Item) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.item = item;
  }
  /**
   * Adds {@link Equipper#item} to the player's bag when the received color
   * matches {@link Equipper#color}. Skips silently when there is no player
   * in the event.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && event.player) event.player.bag.add(this.item);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, item]: string[]) {
      return new Equipper(Registry.terrain(terrain), colorByName(color) ?? NONE, Registry.item(item));
    }
    store(e: Equipper) {
      return `Equipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
    }
    example() {
      return new Equipper(Registry.terrain("Floor"), RED, Registry.item("Crowbar"));
    }
    template() {
      return "Equipper|{terrain}|{color}|{item}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Removes a specific item from the player's bag when a matching color event
 * is received.
 *
 * Typical use: consume a required item as part of a puzzle or trade — e.g.
 * take the Chalice from the player when it is delivered to an altar that
 * fires a color event.
 *
 * @extends Decorator
 */
export class Unequipper extends Decorator {
  item: Item;
  constructor(terrain: Terrain, color: Color, item: Item) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.item = item;
  }
  /**
   * Removes {@link Unequipper#item} from the player's bag when the received
   * color matches {@link Unequipper#color}. Skips silently when there is no
   * player in the event.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && event.player)
      event.player.bag.remove(this.item);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, item]: string[]) {
      return new Unequipper(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        Registry.item(item),
      );
    }
    store(e: Unequipper) {
      return `Unequipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
    }
    example() {
      return new Unequipper(Registry.terrain("Floor"), RED, Registry.item("Crowbar"));
    }
    template() {
      return "Unequipper|{terrain}|{color}|{item}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Re-broadcasts a different color event when it receives its own color event,
 * acting as a color-channel multiplexer.
 *
 * Typical use: fan out one signal to multiple independent listeners on
 * different color channels, or chain together sequences of color-triggered
 * effects without wiring every cell to the same color.
 *
 * @extends Decorator
 */
export class ColorRelay extends Decorator {
  relayTo: Color;
  constructor(terrain: Terrain, color: Color, relayTo: Color) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.relayTo = relayTo;
  }
  /**
   * Fires {@link ColorRelay#relayTo} on the board when the received color
   * matches {@link ColorRelay#color}. Preserves the original origin cell of
   * the triggering event (matching Java, which relays with `origin` rather
   * than this cell) so chained relays don't lose track of where the event
   * actually started.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color) {
      event.board.fireColorEvent(event, this.relayTo, event.originCell ?? cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, relayTo]: string[]) {
      return new ColorRelay(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        colorByName(relayTo) ?? NONE,
      );
    }
    store(cr: ColorRelay) {
      return `ColorRelay|${this.esc(cr.terrain)}|${cr.color.name}|${cr.relayTo.name}`;
    }
    example() {
      return new ColorRelay(Registry.terrain("Floor"), NONE, NONE);
    }
    template() {
      return "ColorRelay|{terrain}|{fromColor}|{toColor}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Ends the game with a victory screen when a matching color event is received.
 *
 * Typical use: trigger the win condition — e.g. place this on the final
 * objective cell and fire its color event when the player delivers the last
 * required item or solves the final puzzle.
 *
 * @extends Decorator
 */
export class EndGame extends Decorator {
  url: string;
  constructor(terrain: Terrain, color: Color, url: string) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.url = url;
  }
  /**
   * Calls {@link game.gameOver} with the victory URL when the received color
   * matches {@link EndGame#color}.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color) {
      game.gameOver(this.url, true);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, url]: string[]) {
      return new EndGame(Registry.terrain(terrain), colorByName(color) ?? NONE, url);
    }
    store(w: EndGame) {
      return `EndGame|${this.esc(w.terrain)}|${w.color.name}|${w.url}`;
    }
    example() {
      return new EndGame(Registry.terrain("Floor"), NONE, "win.html");
    }
    template() {
      return "EndGame|{terrain}|{color}|{url}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Places an item or spawns an agent on the board when a matching color event
 * is received. The piece is placed either at this decorator's cell or at the
 * origin cell of the triggering event, depending on {@link PieceCreator#atOrigin}.
 *
 * Typical use: spawn a reward or enemy at a fixed location when a puzzle is
 * solved elsewhere — e.g. place a Key on a pedestal or summon a guardian
 * when the player pulls a lever on the other side of the board.
 *
 * @extends Decorator
 */
export class PieceCreator extends Decorator {
  piece: Item | Agent;
  atOrigin: boolean;
  constructor(terrain: Terrain, color: Color, piece: Item | Agent, atOrigin: boolean) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.piece = piece;
    this.atOrigin = atOrigin;
  }
  /**
   * Places {@link PieceCreator#piece} on the target cell when the received
   * color matches {@link PieceCreator#color}. Items are added unconditionally;
   * agents are only placed if the target cell has no existing agent.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color !== this.color) return;
    const target = this.atOrigin ? (event.originCell ?? cell) : cell;
    if (this.piece instanceof Agent && target.agent == null) target.setAgent(this.piece);
    else if (this.piece instanceof Item) target.addItem(this.piece);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, piece, atOrigin]: string[]) {
      return new PieceCreator(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        Registry.item(piece),
        atOrigin === "true",
      );
    }
    store(pc: PieceCreator) {
      const pieceKey =
        typeof pc.piece === "string" ? pc.piece : this.esc(pc.piece);
      return `PieceCreator|${this.esc(pc.terrain)}|${pc.color.name}|${pieceKey}|${pc.atOrigin}`;
    }
    example() {
      return new PieceCreator(Registry.terrain("Floor"), NONE, Registry.item("Crowbar"), false);
    }
    template() {
      return "PieceCreator|{terrain}|{color}|{piece}|{atOrigin}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Destroys agents that interact with this cell, operating in one of two modes
 * depending on whether a color is configured.
 *
 * - **Passive mode** (color is {@link NONE}): destroys any non-player agent
 *   the moment it steps onto this cell, removing it from the cell it entered
 *   from.
 * - **Active mode** (color is set): destroys the agent currently on this cell
 *   when a matching color event is received.
 *
 * Typical use: create invisible kill-zones for agents, or trigger a boss
 * defeat remotely via a color event.
 *
 * @extends Decorator
 */
export class AgentDestroyer extends Decorator {
  constructor(terrain: Terrain, color: Color) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
  }
  /**
   * In passive mode (color is {@link NONE}), removes the agent from the cell
   * it entered from as soon as it steps onto this cell, then runs the same
   * death sequence as Java's Game.die(): fire onDie and, unless that cancels
   * the event, drop a Hit effect on the cell.
   */
  onAgentEnterInternal(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    if (this.color === NONE) {
      cell.getAdjacentCell(dir.reverse)?.removeAgent(agent);
      const dieEvent = game.createEvent();
      agent.onDie(dieEvent, cell);
      if (!dieEvent.isCancelled) {
        cell.addEffect(new Hit(agent));
      }
    }
  }
  /**
   * In active mode, removes the agent on this cell when the received color
   * matches {@link AgentDestroyer#color}, mirroring Java's Game.die(): fire
   * onDie on a fresh event, decoupled from whatever event triggered the
   * color signal (e.g. a Switch's already-cancelled move event), and,
   * unless that cancels the event, remove the agent and drop a Hit effect.
   * Skips silently when the cell has no agent.
   */
  onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
    if (color === this.color && cell.agent) {
      const agent = cell.agent;
      const dieEvent = game.createEvent();
      agent.onDie(dieEvent, cell);
      if (!dieEvent.isCancelled) {
        cell.removeAgent(agent);
        cell.addEffect(new Hit(agent));
      }
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color]: string[]) {
      return new AgentDestroyer(Registry.terrain(terrain), colorByName(color) ?? NONE);
    }
    store(ad: AgentDestroyer) {
      return `AgentDestroyer|${this.esc(ad.terrain)}|${ad.color.name}`;
    }
    example() {
      return new AgentDestroyer(Registry.terrain("Floor"), NONE);
    }
    template() {
      return "AgentDestroyer|{terrain}|{color}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Blocks the player from entering this cell unless they possess a specific
 * flag or item. An optional modal message is shown when movement is blocked,
 * matching the conversational feel of an NPC gate.
 *
 * Typical use: pair with an NPC to simulate a guard who refuses to let the
 * player pass until a condition is met — e.g. require the player to carry
 * a Pass before crossing a checkpoint.
 *
 * @extends Decorator
 */
export class PlayerGate extends Decorator {
  testable: number | string;
  message: string | null;
  constructor(terrain: Terrain, testable: number | string, message: string | null) {
    super(terrain, terrain.name, 0, NONE, terrain.symbol);
    if (testable == null) {
      throw new Error("Flag cannot be null");
    }
    this.testable = testable;
    this.message = message ?? null;
  }
  /**
   * Cancels the movement event if the player does NOT satisfy
   * {@link PlayerGate#testable}, optionally showing a modal message first.
   * Skips silently when the event is already cancelled.
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled) {
      return;
    }
    if (!player.matchesFlagOrItem(this.testable)) {
      event.cancel();
      if (this.message) {
        events.fireModalMessage(this.message);
      }
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new PlayerGate(Registry.terrain(args[0]), args[1], args[2] ?? null);
    }
    store(pg: PlayerGate) {
      return pg.message
        ? `PlayerGate|${this.esc(pg.terrain)}|${pg.testable}|${pg.message}`
        : `PlayerGate|${this.esc(pg.terrain)}|${pg.testable}`;
    }
    example() {
      return new PlayerGate(Registry.terrain("Floor"), POISONED, null);
    }
    template() {
      return "PlayerGate|{terrain}|{testable}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Restricts cell traversal to the player only — all non-player agents are
 * prevented from both entering and exiting.
 *
 * Typical use: confine enemies to a region or corridor without affecting
 * player movement — e.g. line the border of an arena so its inhabitants
 * cannot wander out.
 *
 * @extends Decorator
 */
export class AgentGate extends Decorator {
  /**
   * @param {Terrain} terrain - The underlying terrain this decorator wraps.
   */
  constructor(terrain: Terrain) {
    super(terrain, terrain.name, 0, NONE, terrain.symbol);
  }
  /**
   * Allows entry only for the player; delegates to the wrapped terrain for
   * the player. Non-player agents are only allowed in to attack a player
   * already standing on the cell (matching Java's AgentGate.canEnter, which
   * checks whether the target cell already holds the player).
   */
  canEnter(agent: Agent, cell: Cell, direction: Direction) {
    if (is(PLAYER, agent.flags)) {
      return this.terrain.canEnter(agent, cell, direction);
    }
    return cell.agent != null && cell.agent.is(PLAYER);
  }
  /**
   * Allows exit only for the player; delegates to the wrapped terrain for
   * the player, and returns false for all other agents.
   */
  canExit(agent: Agent, cell: Cell, direction: Direction) {
    if (is(PLAYER, agent.flags)) {
      return this.terrain.canExit(agent, cell, direction);
    }
    return false;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]: string[]) {
      return new AgentGate(Registry.terrain(terrain));
    }
    store(ag: AgentGate) {
      return `AgentGate|${this.esc(ag.terrain)}`;
    }
    example() {
      return new AgentGate(Registry.terrain("Floor"));
    }
    template() {
      return "AgentGate|{terrain}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Displays the symbol of one terrain while behaving like another, creating
 * a hidden terrain effect. When a player with the {@link DETECT_HIDDEN} flag
 * is adjacent, the cell briefly reveals the actual terrain's symbol.
 *
 * Typical use: disguise a dangerous or traversable cell as something else
 * — subclasses {@link SecretPassage} and {@link PitTrap} are the canonical
 * examples.
 */
export class Mimic extends Decorator {
  appearsAs: Terrain;
  constructor(appearsAs: Terrain, actual: Terrain, color: Color) {
    super(actual, appearsAs.name, 0, color, appearsAs.symbol);
    this.appearsAs = appearsAs;
  }
  /** Returns the behavioral terrain (the one being wrapped). */
  getProxiedTerrain() {
    return this.terrain;
  }
  /** Returns the visual terrain (the one whose symbol is displayed). */
  getApparentTerrain() {
    return this.appearsAs;
  }
  /**
   * When the player enters an adjacent cell and has {@link DETECT_HIDDEN},
   * swaps the cell's animation symbol to reveal the actual terrain.
   */
  onAdjacentToInternal(event: GameEvent, cell: Cell) {
    if (event.player.is(DETECT_HIDDEN)) {
      cell.animTerrainSymbol = this.terrain.symbol;
      cell.board.notifyCellChange(cell);
    }
  }
  /**
   * When the player leaves the adjacent position, restores the cell's
   * animation symbol so the disguise is shown again.
   *
   * @override
   * @param {GameEvent} _event - Unused.
   * @param {Cell}      cell
   */
  onNotAdjacentToInternal(event: GameEvent, cell: Cell) {
    cell.animTerrainSymbol = null;
    cell.board.notifyCellChange(cell);
  }
  static SERIALIZER: Serializer = new (class extends BaseSerializer {
    create([appearsAs, actual, color]: string[]) {
      return new Mimic(Registry.terrain(appearsAs), Registry.terrain(actual), colorByName(color) ?? NONE);
    }
    store(m: Mimic) {
      return `Mimic|${this.esc(m.appearsAs)}|${this.esc(m.terrain)}|${m.color.name}`;
    }
    example() {
      return new Mimic(Registry.terrain("Floor"), Registry.terrain("Wall"), NONE);
    }
    template() {
      return "Mimic|{appearsAsTerrain}|{actualTerrain}|{color}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** SecretPassage — looks like Wall but is traversable like Floor. */
export class SecretPassage extends Mimic {
  constructor() {
    super(Registry.terrain("Wall"), Registry.terrain("Floor"), NONE);
  }
  canEnter(agent: Agent, cell: Cell, direction: Direction) {
    if (is(PLAYER, agent.flags)) {
      return this.terrain.canEnter(agent, cell, direction);
    }
    return false;
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("SecretPassage");
    }
    create() {
      return new SecretPassage();
    }
    tag() {
      return "Terrain";
    }
  })();
}

/**
 * Looks like {@link Floor} but drops the player into a {@link Pit} when
 * stepped on. Boulders fill the pit instead of falling; {@link Slider} and
 * {@link Pusher} agents fall through and are removed. Regular agents walk
 * over hidden pits unaffected.
 *
 * Players with {@link DETECT_HIDDEN} see the pit symbol while standing
 * adjacent, then the disguise is restored when they move away.
 */
export class PitTrap extends Mimic {
  constructor() {
    super(Registry.terrain("Floor"), Registry.terrain("Pit"), NONE);
  }
  // PitTrap looks and behaves like Floor for entry purposes. canEnter must
  // return true so the JS movement loop lets the player (and boulders) onto
  // the cell; the actual trap effect is handled in onEnter/onAgentEnter.
  canEnter(agent: Agent, cell: Cell, direction: Direction) {
    return this.appearsAs.canEnter(agent, cell, direction);
  }
  // Override onEnter directly (not onEnterInternal) so that Pit's onEnter
  // never runs — otherwise the Decorator base would call Pit.onEnter first,
  // which cancels the event with "You'd fall into the pit".
  onEnter(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    // Matches Java's PitTrap.onEnter: modal message, terrain revealed
    // immediately (not after the delay), then damage, then a 200ms timer
    // that only fires the fall-through teleport if the player is still alive.
    events.fireModalMessage("You fall through a pit!");
    cell.setTerrain(this.terrain); // reveal the pit
    player.changeHealth(20);
    setTimeout(() => {
      if (player.health > 0) {
        events.fireFallThrough(cell.x, cell.y);
      }
    }, 200);
    // Do not cancel — player moves onto the cell, then gets teleported.
  }
  // Override onAgentEnter directly so that Pit's onAgentEnter never runs.
  // Java PitTrap.onAgentEnter also overrides directly; the Java Javadoc notes
  // "Yes, agents walk right over these things" for non-boulder agents.
  onAgentEnter(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    if (agent instanceof AbstractBoulder) {
      const prevCell = cell.getAdjacentCell(dir.reverse);
      prevCell?.removeAgent(agent);
      cell.setTerrain(Registry.terrain("Floor"));
      events.fireMessage("The boulder fills a hidden pit!", cell);
    } else if (agent instanceof Slider || agent instanceof Pusher) {
      const prevCell = cell.getAdjacentCell(dir.reverse);
      prevCell?.removeAgent(agent);
      cell.setTerrain(this.terrain); // reveal the pit
      events.fireMessage(
        `The ${agent.name.toLowerCase()} falls through a hidden pit`, cell
      );
    }
    // Regular agents walk right over hidden pits (no cancel, no action).
  }
  onAdjacentTo(event: GameEvent, cell: Cell) {
    if (event.player.is(DETECT_HIDDEN)) {
      cell.animTerrainSymbol = (Registry.terrain("Pit")).symbol;
      cell.board.notifyCellChange(cell);
    }
  }
  onNotAdjacentTo(event: GameEvent, cell: Cell) {
    cell.animTerrainSymbol = null;
    cell.board.notifyCellChange(cell);
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("PitTrap");
    }
    create() {
      return new PitTrap();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/** Base for containers that release a cloud when opened. */
class TrapContainerBase extends Decorator {
  cloudType: string;
  #detected: Sym;
  constructor(terrain: Terrain, cloudType: string) {
    super(terrain, terrain.name, 0, NONE, terrain.symbol);
    this.cloudType = cloudType;
    const s = terrain.symbol;
    this.#detected = Sym.of(s.entity, s.getColor(false) ?? NONE, RED, s.getColor(true) ?? NONE, RED);
  }
  static #MESSAGES: Record<string, string> = {
    PoisonCloud: "A poison trap!",
    EnergyCloud: "An energy trap!",
    ResistancesCloud: "A resistance trap!",
  };
  /**
   * Rather than triggering immediately on entry, the trap wraps the
   * container's just-created Open effect so it fires when the open
   * animation completes — mirrors Java's TrappedOpenCommand, which
   * decorates the Open effect's Command.
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    for (const effect of cell.effects) {
      if (effect instanceof Open && !effect.trapped) {
        const original = effect.onComplete;
        effect.trapped = true;
        effect.onComplete = () => {
          events.fireMessage(TrapContainerBase.#MESSAGES[this.cloudType], cell);
          cell.createCloud(this.cloudType);
          original();
        };
        return;
      }
    }
  }
  /**
   * When the player is adjacent and has {@link DETECT_HIDDEN}, reveals the
   * trap by swapping in a red-highlighted version of the container's own
   * symbol — but only for Chest/Crate containers, matching Java's
   * TrapContainerBase.onAdjacentToInternal.
   */
  onAdjacentToInternal(event: GameEvent, cell: Cell) {
    if (event.player.is(DETECT_HIDDEN) && (this.terrain instanceof Chest || this.terrain instanceof Crate)) {
      cell.animTerrainSymbol = this.#detected;
      cell.board.notifyCellChange(cell);
    }
  }
  onNotAdjacentToInternal(event: GameEvent, cell: Cell) {
    cell.animTerrainSymbol = null;
    cell.board.notifyCellChange(cell);
  }
}

/**
 * Releases an {@link EnergyCloud} effect onto the cell when the player
 * enters, simulating a trap hidden inside a container.
 *
 * @extends TrapContainerBase
 */
export class EnergyTrapContainer extends TrapContainerBase {
  constructor(terrain: Terrain) {
    super(terrain, "EnergyCloud");
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]: string[]) {
      return new EnergyTrapContainer(Registry.terrain(terrain));
    }
    store(tc: EnergyTrapContainer) {
      return `EnergyTrapContainer|${this.esc(tc.terrain)}`;
    }
    example() {
      return new EnergyTrapContainer(Registry.terrain("Floor"));
    }
    template() {
      return "EnergyTrapContainer|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}
/**
 * Releases a {@link PoisonCloud} effect onto the cell when the player
 * enters, simulating a trap hidden inside a container.
 *
 * @extends TrapContainerBase
 */
export class PoisonTrapContainer extends TrapContainerBase {
  constructor(terrain: Terrain) {
    super(terrain, "PoisonCloud");
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]: string[]) {
      return new PoisonTrapContainer(Registry.terrain(terrain));
    }
    store(tc: PoisonTrapContainer) {
      return `PoisonTrapContainer|${this.esc(tc.terrain)}`;
    }
    example() {
      return new PoisonTrapContainer(Registry.terrain("Floor"));
    }
    template() {
      return "PoisonTrapContainer|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}
/**
 * Releases a {@link ResistancesCloud} effect onto the cell when the player
 * enters, simulating a trap hidden inside a container.
 *
 * @extends TrapContainerBase
 */
export class ResistancesTrapContainer extends TrapContainerBase {
  constructor(terrain: Terrain) {
    super(terrain, "ResistancesCloud");
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]: string[]) {
      return new ResistancesTrapContainer(Registry.terrain(terrain));
    }
    store(tc: ResistancesTrapContainer) {
      return `ResistancesTrapContainer|${this.esc(tc.terrain)}`;
    }
    example() {
      return new ResistancesTrapContainer(Registry.terrain("Floor"));
    }
    template() {
      return "ResistancesTrapContainer|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Cliff — decorator that enforces directional cliff traversal.
 *
 * The player can only enter a Cliff cell from a matching-terrain side, and
 * only exit to a matching-terrain side (enforced by onEnterInternal /
 * onExitInternal, which cancel with a "too steep" message otherwise —
 * mirrors Java's Cliff.onEnterInternal/onExitInternal exactly).
 *
 * Non-player agents are the mirror image: Java's canEnter/canExit (which in
 * the original engine are only ever consulted for agent AI movement, never
 * for the player) allow a monster in/out only via a MISMATCHED neighbor.
 * Since this port's canEnter is also consulted for the player's own physical
 * traversability check, canEnter/canExit branch on PLAYER (same pattern as
 * AgentGate/SecretPassage in this file) so player behavior stays governed by
 * the matching-terrain rule above, while non-player agents get Java's
 * mismatched-terrain rule.
 */
export class Cliff extends Decorator {
  constructor(terrain: Terrain) {
    super(terrain, terrain.name, 0, NONE, terrain.symbol);
  }
  proxy(terrain: Terrain) {
    return new Cliff(terrain);
  }
  canEnter(agent: Agent, cell: Cell, dir: Direction) {
    if (!super.canEnter(agent, cell, dir)) {
      return false;
    }
    const behind = cell.getAdjacentCell(dir.reverse);
    if (!behind) {
      return true;
    }
    const matches = behind.getApparentTerrain()?.name === this.terrain.name;
    return is(PLAYER, agent.flags) ? matches : !matches;
  }
  canExit(agent: Agent, cell: Cell, dir: Direction) {
    if (!super.canExit(agent, cell, dir)) {
      return false;
    }
    const ahead = cell.getAdjacentCell(dir);
    if (!ahead) {
      return true;
    }
    const matches = ahead.getApparentTerrain()?.name === this.terrain.name;
    return is(PLAYER, agent.flags) ? matches : !matches;
  }
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    const behind = cell.getAdjacentCell(dir.reverse);
    if (behind?.getApparentTerrain()?.name !== this.terrain.name) {
      event.cancel("It's too steep to climb up here.", cell);
    }
  }
  onExitInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    const ahead = cell.getAdjacentCell(dir);
    if (ahead?.getApparentTerrain()?.name !== this.terrain.name) {
      event.cancel("It's too steep to climb down here.", cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]: string[]) {
      return new Cliff(Registry.terrain(terrain));
    }
    store(c: Cliff) {
      return `Cliff|${this.esc(c.terrain)}`;
    }
    example() {
      return new Cliff(Registry.terrain("Floor"));
    }
    template() {
      return "Cliff|{terrain}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Fires a color event on the board at a fixed interval, acting as a
 * clock signal for other color-event-driven decorators.
 *
 * Typical use: drive periodic effects such as toggling a {@link DualTerrain}
 * or pulsing a {@link ColorRelay} — e.g. open and close a gate every
 * 20 frames, or spawn an agent at regular intervals.
 *
 * @extends Decorator
 */
export class Timer extends Decorator implements Animated {
  frames: number;
  /**
   * @param {Terrain} terrain - The underlying terrain this decorator wraps.
   * @param {Color}   color   - The color event broadcast on each tick.
   * @param {number}  frames  - Interval in animation frames between firings.
   *                            Values ≤ 0 are clamped to 1.
   */
  constructor(terrain: Terrain, color: Color, frames: number) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.frames = frames > 0 ? frames : 1;
  }
  randomSeed(): boolean {
    return false;
  }
  /** Returns a new {@link Timer} wrapping the given terrain, preserving color and interval. */
  proxy(terrain: Terrain) {
    return new Timer(terrain, this.color, this.frames);
  }
  /**
   * Called by the {@link AnimationManager} on each animation tick. Fires
   * the color event on the board whenever the frame counter is a positive
   * multiple of {@link Timer#_frames}.
   */
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame > 0 && frame % this.frames === 0) {
      cell.board.fireColorEvent(event, this.color, cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new Timer(
        Registry.terrain(args[0]),
        colorByName(args[1]) ?? NONE,
        parseInt(args[2]) || 1,
      );
    }
    store(t: Timer) {
      return `Timer|${this.esc(t.terrain)}|${t.color.name}|${t.frames}`;
    }
    example() {
      return new Timer(Registry.terrain("Floor"), NONE, 10);
    }
    template() {
      return "Timer|{terrain}|{color}|{frames}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

export function registerDecorators() {
  Registry.register("Sign", Sign.SERIALIZER);
  Registry.register("Rubble", Rubble.SERIALIZER);
  Registry.register("DualTerrain", DualTerrain.SERIALIZER);
  Registry.register("Flagger", Flagger.SERIALIZER);
  Registry.register("Unflagger", Unflagger.SERIALIZER);
  Registry.register("Messenger", Messenger.SERIALIZER);
  Registry.register("Equipper", Equipper.SERIALIZER);
  Registry.register("Unequipper", Unequipper.SERIALIZER);
  Registry.register("ColorRelay", ColorRelay.SERIALIZER);
  Registry.register("EndGame", EndGame.SERIALIZER);
  Registry.register("PieceCreator", PieceCreator.SERIALIZER);
  Registry.register("AgentDestroyer", AgentDestroyer.SERIALIZER);
  Registry.register("PlayerGate", PlayerGate.SERIALIZER);
  Registry.register("AgentGate", AgentGate.SERIALIZER);
  Registry.register("Mimic", Mimic.SERIALIZER);
  Registry.register("SecretPassage", SecretPassage.SERIALIZER);
  Registry.register("PitTrap", PitTrap.SERIALIZER);
  Registry.register("EnergyTrapContainer", EnergyTrapContainer.SERIALIZER);
  Registry.register("PoisonTrapContainer", PoisonTrapContainer.SERIALIZER);
  Registry.register(
    "ResistancesTrapContainer",
    ResistancesTrapContainer.SERIALIZER,
  );
  Registry.register("Cliff", Cliff.SERIALIZER);
  Registry.register("Timer", Timer.SERIALIZER);
}