pieces/terrain/triggers.ts

import { Agent } from "../../core/agent.ts";
import { BaseSerializer } from "../../core/serializer.ts";
import { Cell } from "../../core/cell.ts";
import { Color, NONE, STEELBLUE, colorByName } from "../../core/color.ts";
import { Decorator } from "./decorators.ts";
import { Direction } from "../../core/direction.ts";
import { events } from "../../core/event-bus.ts";
import { Floor } from "./basic.ts";
import { GameEvent } from "../../core/game-event.ts";
import { getFlag } from "../../core/flags.ts";
import { Item } from "../../core/item.ts";
import { Player } from "../../core/player.ts";
import { Registry } from "../../core/registry.ts";
import { Terrain } from "../../core/terrain.ts";
import { TerrainUtils } from "../../core/terrain-utils.ts";

/**
 * Fires a color event (and optional message) when the player steps on it.
 * Persistent — fires every time.
 *
 * @extends Decorator
 */
export class Trigger extends Decorator {
  message: string | null;
  constructor(terrain: Terrain, color: Color, message: string | null) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.message = message;
  }
  /**
   * Fires the color event and, if set, shows a modal message to the player.
   * Skips silently when the event is already cancelled.
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled) {
      return;
    }
    event.board.fireColorEvent(event, this.color, cell);
    if (this.message) {
      events.fireMessage(this.message, cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new Trigger(
        Registry.terrain(args[0]),
        colorByName(args[1]) ?? NONE,
        args[2] ?? null,
      );
    }
    store(t: Trigger) {
      const base = `Trigger|${this.esc(t.terrain)}|${t.color.name}`;
      return t.message ? `${base}|${t.message}` : base;
    }
    example() {
      return new Trigger(Registry.terrain("Floor"), STEELBLUE, null);
    }
    template() {
      return "Trigger|{terrain}|{color}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Fires a color event (and optional message) when the player steps on it,
 * but only when the player <em>possesses</em> the specified flag or item.
 * Persistent — fires every time the condition is met.
 *
 * @extends Trigger
 */
export class TriggerIf extends Trigger {
  test: string;
  constructor(terrain: Terrain, test: string, color: Color, message: string | null) {
    super(terrain, color, message);
    this.test = test;
  }
  /**
   * Fires the trigger if the player possesses the tested flag or item.
   * Skips silently when the event is already cancelled or when the player
   * lacks the test value.
   *
   * @override
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled) {
      return;
    }
    if (_playerHas(player, this.test)) {
      super.onEnterInternal(event, player, cell, dir);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new TriggerIf(
        Registry.terrain(args[0]),
        args[1],
        colorByName(args[2]) ?? NONE,
        args[3] ?? null,
      );
    }
    store(t: TriggerIf) {
      const base = `TriggerIf|${this.esc(t.terrain)}|${t.test}|${t.color.name}`;
      return t.message ? `${base}|${t.message}` : base;
    }
    example() {
      return new TriggerIf(Registry.terrain("Floor"), "poisoned", STEELBLUE, null);
    }
    template() {
      return "TriggerIf|{terrain}|{testValue}|{color}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Fires a color event (and optional message) when the player steps on it,
 * but only when the player does <em>not</em> possess the specified flag or
 * item. Persistent — fires every time the condition is met.
 *
 * @extends Trigger
 */
export class TriggerIfNot extends Trigger {
  test: string;
  /**
   * @param {Terrain}     terrain - The underlying terrain this decorator wraps.
   * @param {string}      test    - Flag name or item name whose <em>absence</em>
   *                               allows the trigger to fire.
   * @param {Color}       color   - Color event broadcast to the board on firing.
   * @param {string|null} message - Optional modal message shown to the player.
   */
  constructor(terrain: Terrain, test: string, color: Color, message: string | null) {
    super(terrain, color, message);
    this.test = test;
  }
  /**
   * Fires the trigger if the player does not have the tested flag or item.
   * Skips silently when the event is already cancelled or when the player
   * possesses the test value.
   *
   * @override
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled) {
      return;
    }
    if (!_playerHas(player, this.test)) {
      super.onEnterInternal(event, player, cell, dir);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new TriggerIfNot(
        Registry.terrain(args[0]),
        args[1],
        colorByName(args[2]) ?? NONE,
        args[3] ?? null,
      );
    }
    store(t: TriggerIfNot) {
      const base = `TriggerIfNot|${this.esc(t.terrain)}|${t.test}|${t.color.name}`;
      return t.message ? `${base}|${t.message}` : base;
    }
    example() {
      return new TriggerIfNot(Registry.terrain("Floor"), "poisoned", STEELBLUE, null);
    }
    template() {
      return "TriggerIfNot|{terrain}|{testValue}|{color}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * Fires a color event (and optional message) when the player steps on it,
 * then removes itself so it will never fire again.
 *
 * Typical use: play a one-time scene or reward the first time a player
 * reaches a location — e.g. show an introductory message when entering a
 * new area for the first time.
 *
 * @extends Trigger
 */
export class TriggerOnce extends Trigger {
  /**
   * Fires the color event and, if set, shows a modal message to the player
   * (unlike the base {@link Trigger}, which shows an inline message), then
   * removes this decorator from the cell.
   * Skips silently when the event is already cancelled.
   *
   * @override
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled) {
      return;
    }
    event.board.fireColorEvent(event, this.color, cell);
    if (this.message) {
      events.fireModalMessage(this.message);
    }
    TerrainUtils.removeDecorator(event, this);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, message]: string[]) {
      return new TriggerOnce(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        message ?? null,
      );
    }
    store(t: TriggerOnce) {
      const base = `TriggerOnce|${this.esc(t.terrain)}|${t.color.name}`;
      return t.message ? `${base}|${t.message}` : base;
    }
    example() {
      return new TriggerOnce(Registry.terrain("Floor"), STEELBLUE, null);
    }
    template() {
      return "TriggerOnce|{terrain}|{color}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * A one-shot trigger that fires only when the player <em>does</em> possess
 * the specified flag or item. Once it fires it removes itself, so it will
 * never fire again.
 *
 * Typical use: gate an event or reward on the player already carrying a
 * required item — e.g. react to the player arriving at a location while
 * holding the Chalice, then vanish so the scene plays out only once.
 *
 * @extends TriggerOnce
 */
export class TriggerOnceIf extends TriggerOnce {
  test: string;
  constructor(terrain: Terrain, test: string, color: Color, message: string | null) {
    super(terrain, color, message);
    this.test = test;
  }
  /**
   * Fires the trigger if the player possesses the tested flag or item.
   * Skips silently when the event is already cancelled or when the player
   * lacks the test value. On a successful fire the trigger removes itself
   * via {@link TriggerOnce#onEnterInternal}.
   *
   * @override
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled || !_playerHas(player, this.test)) {
      return;
    }
    super.onEnterInternal(event, player, cell, dir);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new TriggerOnceIf(
        Registry.terrain(args[0]),
        args[1],
        colorByName(args[2]) ?? NONE,
        args[3] ?? null,
      );
    }
    store(t: TriggerOnceIf) {
      const base = `TriggerOnceIf|${this.esc(t.terrain)}|${t.test}|${t.color.name}`;
      return t.message ? `${base}|${t.message}` : base;
    }
    example() {
      return new TriggerOnceIf(Registry.terrain("Floor"), "poisoned", STEELBLUE, null);
    }
    template() {
      return "TriggerOnceIf|{terrain}|{testValue}|{color}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * A one-shot trigger that fires only when the player does <em>not</em> possess
 * the specified flag or item. Once it fires it removes itself, so it will
 * never fire again.
 *
 * Typical use: gate a warning or event on the player lacking a required
 * prerequisite — e.g. display a reminder message until the player picks up
 * the required item, at which point the trigger is gone and never interrupts
 * them again.
 *
 * @extends TriggerOnce
 */
export class TriggerOnceIfNot extends TriggerOnce {
  test: string;
  constructor(terrain: Terrain, test: string, color: Color, message: string | null) {
    super(terrain, color, message);
    this.test = test;
  }
  /**
   * Fires the trigger if the player does not have the tested flag or item.
   * Skips silently when the event is already cancelled or when the player
   * possesses the test value. On a successful fire the trigger removes itself
   * via {@link TriggerOnce#onEnterInternal}.
   *
   * @override
   */
  onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.isCancelled || _playerHas(player, this.test)) {
      return;
    }
    super.onEnterInternal(event, player, cell, dir);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args: string[]) {
      return new TriggerOnceIfNot(
        Registry.terrain(args[0]),
        args[1],
        colorByName(args[2]) ?? NONE,
        args[3] ?? null,
      );
    }
    store(t: TriggerOnceIfNot) {
      const base = `TriggerOnceIfNot|${this.esc(t.terrain)}|${t.test}|${t.color.name}`;
      return t.message ? `${base}|${t.message}` : base;
    }
    example() {
      return new TriggerOnceIfNot(Registry.terrain("Floor"), "poisoned", STEELBLUE, null);
    }
    template() {
      return "TriggerOnceIfNot|{terrain}|{testValue}|{color}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * A one-shot trigger that fires when a specific named item is dropped on
 * this cell. Once it fires it removes itself, so it will never fire again.
 *
 * Typical use: detect when the player places a particular item at a location
 * — e.g. an altar that reacts only when the Chalice is set down — then
 * vanish so the scene plays out exactly once.
 *
 * @extends Decorator
 */
export class TriggerOnceOnDrop extends Decorator {
  itemName: string;
  constructor(terrain: Terrain, color: Color, itemName: string) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.itemName = itemName;
  }
  /**
   * Fires the color event and removes this trigger when the dropped item's
   * name exactly matches {@link TriggerOnceOnDrop#itemName}.
   * Skips silently when the event is already cancelled or the item does not
   * match.
   *
   * @override
   */
  onDropInternal(event: GameEvent, cell: Cell, item: Item) {
    if (event.isCancelled) {
      return;
    }
    if (_matchesNameOrType(item, this.itemName)) {
      event.board.fireColorEvent(event, this.color, cell);
      TerrainUtils.removeDecorator(event, this);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, itemName]: string[]) {
      return new TriggerOnceOnDrop(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        itemName,
      );
    }
    store(t: TriggerOnceOnDrop) {
      return `TriggerOnceOnDrop|${this.esc(t.terrain)}|${t.color.name}|${t.itemName}`;
    }
    example() {
      return new TriggerOnceOnDrop(Registry.terrain("Floor"), STEELBLUE, "GoldCoin");
    }
    template() {
      return "TriggerOnceOnDrop|{terrain}|{color}|{itemName}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/**
 * A one-shot trigger that fires when a specific item is picked up from this
 * cell. The item is matched by its exact name or by its Registry type ID
 * (using {@link _matchesNameOrType}). Once it fires it removes itself, so it
 * will never fire again.
 *
 * Typical use: react to the player collecting a key item from a location
 * — e.g. the moment the player lifts the Amulet off a pedestal — then
 * vanish so the event fires only once.
 *
 * @extends Decorator
 */
export class TriggerOnceOnPickup extends Decorator {
  flagStr: string;
  /**
   * @param {Terrain} terrain  - The underlying terrain this decorator wraps.
   * @param {Color}   color    - Color event broadcast to the board on firing.
   * @param {string}  flagStr  - Item name or flag string that must match the
   *                             picked-up item to trigger the event.
   */
  constructor(terrain: Terrain, color: Color, flagStr: string) {
    super(terrain, terrain.name, 0, color, terrain.symbol);
    this.flagStr = flagStr;
  }
  /**
   * Fires the color event and removes this trigger when the picked-up item
   * matches {@link TriggerOnceOnPickup#flagStr} by name or by Registry type ID.
   * Skips silently when the event is already cancelled or the item does not
   * match.
   *
   * @override
   */
  onPickupInternal(event: GameEvent, cell: Cell, agent: Agent, item: Item) {
    if (event.isCancelled) {
      return;
    }
    if (_matchesNameOrType(item, this.flagStr)) {
      event.board.fireColorEvent(event, this.color, cell);
      TerrainUtils.removeDecorator(event, this);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, flagStr]: string[]) {
      return new TriggerOnceOnPickup(
        Registry.terrain(terrain),
        colorByName(color) ?? NONE,
        flagStr,
      );
    }
    store(t: TriggerOnceOnPickup) {
      return `TriggerOnceOnPickup|${this.esc(t.terrain)}|${t.color.name}|${t.flagStr}`;
    }
    example() {
      return new TriggerOnceOnPickup(
        Registry.terrain("Floor"),
        STEELBLUE,
        "Gold Coin",
      );
    }
    template() {
      return "TriggerOnceOnPickup|{terrain}|{color}|{flag}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

export function registerTriggers() {
  Registry.register("Trigger", Trigger.SERIALIZER);
  Registry.register("TriggerIf", TriggerIf.SERIALIZER);
  Registry.register("TriggerIfNot", TriggerIfNot.SERIALIZER);
  Registry.register("TriggerOnce", TriggerOnce.SERIALIZER);
  Registry.register("TriggerOnceIf", TriggerOnceIf.SERIALIZER);
  Registry.register("TriggerOnceIfNot", TriggerOnceIfNot.SERIALIZER);
  Registry.register("TriggerOnceOnDrop", TriggerOnceOnDrop.SERIALIZER);
  Registry.register("TriggerOnceOnPickup", TriggerOnceOnPickup.SERIALIZER);
}

function _playerHas(player: Player, test: string) {
  if (!test) {
    return false;
  }
  const flag = getFlag(test);
  if (flag !== -1 && player.is(flag)) {
    return true;
  }
  return player.bag.find((i) => i.name === test) != null;
}

/**
 * True if `item`'s display name matches `name` exactly, or if its Registry
 * type ID (the piece's serialization key, up to the first "|") matches.
 * Mirrors the Java `Util.getType(item)` comparison used by the "on drop" and
 * "on pickup" triggers.
 */
function _matchesNameOrType(item: Item, name: string): boolean {
  if (item.name === name) {
    return true;
  }
  try {
    return Registry.serialize(item).split("|")[0] === name;
  } catch (_) {
    return false;
  }
}