pieces/terrain/features.js

import { Crowbar, Crystal, Grenade, Key } from "../items/items.js";
import { oscillate } from "../effects/effects.js";
import { AbstractBoulder } from "../../pieces/agents/creatures.js";
import { Terrain } from "../../core/terrain.js";
import { Registry } from "../../core/registry.js";
import { TerrainUtils } from "../../core/terrain-utils.js";
import { events } from "../../core/events.js";
import {
  TRAVERSABLE,
  PENETRABLE,
  VERTICAL,
  DETECT_HIDDEN,
  AMMUNITION,
} from "../../core/flags.js";
import {
  WHITE,
  BLACK,
  NEARBLACK,
  BARELY_BUILDING_WALL,
  BUILDING_FLOOR,
  BUILDING_WALL,
  BURLYWOOD,
  BURNTWOOD,
  DARKGOLDENROD,
  DARKGRAY,
  DARKSLATEGRAY,
  LIGHTSLATEGRAY,
  LIMEGREEN,
  LOW_ROCKS,
  SADDLEBROWN,
  SANDYBROWN,
  STEELBLUE,
  colorByName,
  NONE,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { ON, OFF, stateFromString } from "../../core/state.js";
import {
  directionByName,
  NONE as DIR_NONE,
  WEST,
  EAST,
} from "../../core/direction.js";

// ── Door ─────────────────────────────────────────────────────────────────────

/**
 * A door that is either open (ON) or locked (OFF). A matching color key
 * unlocks/relocks it; a color event also toggles it.
 */
export class Door extends Terrain {
  constructor(color, state) {
    const sym = state.isOn()
      ? Symbol.of("·", color, null, color, BUILDING_FLOOR)
      : Symbol.of("·", WHITE, color, BUILDING_FLOOR, color);
    super(color.name + " Door", 0, color, sym);
    this.state = state;
  }
  onColorEvent(_event, color, cell) {
    if (color !== this.color) return;
    TerrainUtils.toggleCellState(cell, this, this.state);
  }
  canEnter(_agent, _cell, direction) {
    return this.state.isOn() && !direction.isDiagonal();
  }
  canExit(_agent, _cell, direction) {
    return !direction.isDiagonal();
  }
  onEnter(event, player, cell, dir) {
    if (dir.isDiagonal() || dir.isVertical()) {
      event.cancel();
      return;
    }
    const item = player.bag.getSelected?.() ?? player.bag.selected;
    if (this.state.isOff()) {
      if (
        item instanceof Key &&
        item.color === this.color &&
        cell.agent == null
      ) {
        TerrainUtils.toggleCellState(cell, this, this.state);
        player.bag.remove(item);
        event.cancel();
      } else {
        event.cancelWithMessage(cell, "The door is locked.");
      }
    } else {
      if (
        item instanceof Key &&
        item.color === this.color &&
        cell.agent == null
      ) {
        TerrainUtils.toggleCellState(cell, this, this.state);
        player.bag.remove(item);
        event.cancelWithMessage(cell, "You lock the door.");
      }
    }
  }
  onExit(event, _player, _cell, dir) {
    if (dir.isDiagonal() || dir.isVertical()) event.cancel();
  }
  onAgentEnter(event, agent, cell, dir) {
    if (agent instanceof AbstractBoulder) {
      event.cancelWithMessage(cell, "It's too big.");
    } else if (dir.isDiagonal() || dir.isVertical() || this.state.isOff()) {
      event.cancel();
    }
  }
  onAgentExit(event, _agent, _cell, dir) {
    if (dir.isDiagonal() || dir.isVertical()) event.cancel();
  }
  onFlyOver(event, _cell, flier) {
    if (this.state.isOff() || flier.direction?.isDiagonal()) event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, state]) {
      return new Door(colorByName(color) ?? NONE, stateFromString(state));
    }
    store(d) {
      return `Door|${d.color.name}|${d.state.name}`;
    }
    example() {
      return new Door(NONE, OFF);
    }
    template(_id) {
      return "Door|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Gate ─────────────────────────────────────────────────────────────────────

/**
 * A gate that agents can open by walking through it straight-on.
 * It auto-closes after the agent passes.
 */
export class Gate extends Terrain {
  constructor(state) {
    const sym = state.isOff()
      ? Symbol.of("#", WHITE, null, BLACK, BUILDING_FLOOR)
      : state.isOn()
        ? Symbol.of("#", NEARBLACK, null, BUILDING_WALL, BUILDING_FLOOR)
        : null;
    super("Gate", TRAVERSABLE | PENETRABLE, sym);
    this.state = state;
  }
  canEnter(_agent, _cell, direction) {
    return !direction.isDiagonal();
  }
  canExit(_agent, _cell, direction) {
    return !direction.isDiagonal();
  }
  onEnter(event, _player, cell, dir) {
    if (dir.isDiagonal()) {
      event.cancel();
      return;
    }
    if (this.state.isOff()) {
      TerrainUtils.toggleCellState(cell, this, this.state);
      event.cancel();
      events.fireMessage(cell, "You open the gate");
    }
  }
  onExit(event, _player, cell, dir) {
    if (dir.isDiagonal()) {
      event.cancel();
      return;
    }
    TerrainUtils.toggleCellState(cell, this, this.state);
  }
  onAgentEnter(event, agent, cell, dir) {
    if (agent instanceof AbstractBoulder) {
      event.cancelWithMessage(cell, "It's too big.");
    } else if (this.state.isOff()) {
      TerrainUtils.toggleCellState(cell, this, this.state);
      event.cancel();
    }
  }
  onAgentExit(_event, _agent, cell, _dir) {
    TerrainUtils.toggleCellState(cell, this, this.state);
  }
  onFlyOver(event, _cell, flier) {
    if (flier.direction?.isDiagonal()) event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([state]) {
      return new Gate(stateFromString(state));
    }
    store(g) {
      return `Gate|${g.state.name}`;
    }
    example() {
      return new Gate(OFF);
    }
    template(_id) {
      return "Gate|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/** Rusty gate — permanently open; player can enter but it's impassable for items */
class RustyGate extends Terrain {
  constructor() {
    super(
      "Rusty Gate",
      PENETRABLE,
      null,
      Symbol.of("#", SANDYBROWN, null, SADDLEBROWN, BUILDING_FLOOR),
    );
  }
  onEnter(event, _player, cell, _dir) {
    event.cancelWithMessage(cell, "The gate is too rusty to open");
  }
  onFlyOver(event, _cell, flier) {
    if (flier.direction.isDiagonal()) {
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("RustyGate");
    }
    create(_args) {
      return new RustyGate();
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Stairs / Vertical exits ───────────────────────────────────────────────────

class StairsUp extends Terrain {
  constructor() {
    super(
      "Stairs Up",
      TRAVERSABLE | PENETRABLE | VERTICAL,
      Symbol.of("<", WHITE, null, BLACK, BUILDING_FLOOR),
    );
  }
  onEnter(event, _player, cell, _dir) {
    events.fireMessage(cell, "Use 'z' to go up");
  }
  onExit(event, _player, _cell, dir) {
    if (dir.isVertical() && dir.name === "down") event.cancel();
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("StairsUp");
    }
    create(_args) {
      return new StairsUp();
    }
    tag() {
      return "Room Features";
    }
  })();
}

class StairsDown extends Terrain {
  constructor() {
    super(
      "Stairs Down",
      TRAVERSABLE | PENETRABLE | VERTICAL,
      Symbol.of(">", WHITE, null, BLACK, BUILDING_FLOOR),
    );
  }
  onEnter(event, _player, cell, _dir) {
    events.fireMessage(cell, "Use 'z' to go down");
  }
  onExit(event, _player, _cell, dir) {
    if (dir.isVertical() && dir.name === "up") event.cancel();
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("StairsDown");
    }
    create(_args) {
      return new StairsDown();
    }
    tag() {
      return "Room Features";
    }
  })();
}

class CaveEntrance extends Terrain {
  constructor() {
    super(
      "Cave Entrance",
      TRAVERSABLE | PENETRABLE | VERTICAL,
      Symbol.of("∩", WHITE, NONE, NEARBLACK, LOW_ROCKS),
    );
  }
  onEnter(event, _player, cell, _dir) {
    const msg = event.board?.outside
      ? "Use 'z' to enter the cave"
      : "Use 'z' to exit the cave";
    events.fireMessage(cell, msg);
  }
  onExit(event, _player, _cell, dir) {
    if (!dir.isVertical()) return;
    // Outside: can only go DOWN into the cave; inside: can only go UP out of it
    if (
      (event.board?.outside && dir.name === "up") ||
      (!event.board?.outside && dir.name === "down")
    ) {
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("CaveEntrance");
    }
    create(_args) {
      return new CaveEntrance();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

// ── Containers ────────────────────────────────────────────────────────────────

/**
 * Locked chest. Requires a matching color key.
 * item and count may be null (empty chest).
 */
export class Chest extends Terrain {
  constructor(item, count, color) {
    super(
      `${color.name} Chest`,
      PENETRABLE,
      color,
      Symbol.of("⊠", color, null, color, BUILDING_FLOOR),
    );
    this.item = item;
    this.count = count ?? 1;
  }
  onEnter(event, player, cell, _dir) {
    if (!cell.isBagEmpty) return;
    const sel = player.bag.getSelected?.() ?? player.bag.selected;
    if (sel instanceof Key) {
      if (sel.color === this.color) {
        cell.openContainer(
          "chest",
          this.item,
          this.count,
          "EmptyChest",
          player,
        );
        player.bag.remove(sel);
      } else {
        events.fireMessage(cell, "The key is not the right color");
      }
    } else {
      events.fireMessage(cell, "A large locked chest blocks your way");
    }
    event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      if (args.length === 1)
        return new Chest(null, 1, colorByName(args[0]) ?? NONE);
      if (args.length === 2)
        return new Chest(_ri(args[0]), 1, colorByName(args[1]) ?? NONE);
      return new Chest(
        _ri(args[0]),
        parseInt(args[1]) || 1,
        colorByName(args[2]) ?? NONE,
      );
    }
    store(c) {
      return c.item
        ? `Chest|${this.esc(c.item)}|${c.count}|${c.color.name}`
        : `Chest|${c.color.name}`;
    }
    example() {
      return new Chest(null, 1, NONE);
    }
    template(_id) {
      return "Chest|{item?}|{count?}|{color}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Board-Strewn Floor — the remnants of a smashed crate. Traversable.
 * Mirrors Java's Boards (OpeningMarker subclass).
 */
class Boards extends Terrain {
  constructor() {
    super(
      "Board-Strewn Floor",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("≠", NEARBLACK, null, BARELY_BUILDING_WALL, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Boards");
    }
    create(_args) {
      return new Boards();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/** Empty chest — traversable floor tile with hollow chest appearance */
class EmptyChest extends Terrain {
  constructor() {
    super(
      "Empty Chest",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("⊔", NEARBLACK, null, BARELY_BUILDING_WALL, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("EmptyChest");
    }
    create(_args) {
      return new EmptyChest();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Crate — requires a crowbar to open.
 * item and count may be null.
 */
export class Crate extends Terrain {
  constructor(item, count) {
    super(
      "Crate",
      PENETRABLE,
      Symbol.of("⊠", WHITE, null, NEARBLACK, BUILDING_FLOOR),
    );
    this.item = item;
    this.count = count ?? 1;
  }
  onEnter(event, player, cell, _dir) {
    if (!cell.isBagEmpty) return;
    const sel = player.bag.getSelected?.() ?? player.bag.selected;
    if (sel instanceof Crowbar) {
      cell.openContainer("crate", this.item, this.count, "Boards", player);
    } else {
      events.fireMessage(
        cell,
        "A large crate. It can't be moved, but try prying it open",
      );
    }
    event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      if (args.length === 0) return new Crate(null, 1);
      if (args.length === 1) return new Crate(_ri(args[0]), 1);
      return new Crate(_ri(args[0]), parseInt(args[1]) || 1);
    }
    store(c) {
      return c.item ? `Crate|${this.esc(c.item)}|${c.count}` : "Crate";
    }
    example() {
      return new Crate(null, 1);
    }
    template(_id) {
      return "Crate|{item}|{count?}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Urn — smashable container. Item inside; breakable by any attack.
 * item may be null.
 */
export class Urn extends Terrain {
  constructor(item) {
    super(
      "Urn",
      Symbol.of("u", DARKGOLDENROD, BLACK, DARKGOLDENROD, BUILDING_FLOOR),
    );
    this.item = item;
  }
  onEnter(event, player, cell, _dir) {
    cell.openContainer("urn", this.item, 1, "EmptyChest", player);
    event.cancel();
  }
  onFlyOver(event, cell, _flier) {
    cell.openContainer("urn", this.item, 1, "EmptyChest", null);
    event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new Urn(args.length > 0 ? _ri(args[0]) : null);
    }
    store(u) {
      return u.item ? `Urn|${this.esc(u.item)}` : "Urn";
    }
    example() {
      return new Urn(null);
    }
    template(_id) {
      return "Urn|{item?}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Switches / Triggers ───────────────────────────────────────────────────────

/**
 * Switch — fires a color event; toggles appearance.
 * Can be triggered by throwing a non-ammunition item at it.
 */
export class Switch extends Terrain {
  constructor(color, state) {
    const entity = state.isOn() ? "!" : "\u00A1"; // ¡
    super(color.name + " Switch", 0, color, Symbol.of(entity, BLACK, color));
    this.state = state;
  }
  onEnter(event, _player, cell, dir) {
    event.cancel();
    if (dir.isDiagonal()) {
      events.fireMessage(cell, "You must use it square on");
      return;
    }
    TerrainUtils.toggleCellState(cell, this, this.state);
    event.board.fireColorEvent(event, this.color, cell);
  }
  onFlyOver(event, cell, flier) {
    event.cancel();
    if (!flier.item?.is(AMMUNITION)) {
      TerrainUtils.toggleCellState(cell, this, this.state);
      event.board.fireColorEvent(event, this.color, cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, state]) {
      return new Switch(colorByName(color) ?? NONE, stateFromString(state));
    }
    store(s) {
      return `Switch|${s.color.name}|${s.state.name}`;
    }
    example() {
      return new Switch(NONE, OFF);
    }
    template(_id) {
      return "Switch|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * KeySwitch — fires a color event only when the player uses a matching color key.
 */
export class KeySwitch extends Terrain {
  constructor(color, state) {
    const entity = state.isOn() ? "?" : "\u00BF"; // ¿
    super(
      color.name + " Key Switch",
      0,
      color,
      Symbol.of(entity, BLACK, color),
    );
    this.state = state;
  }
  onEnter(event, player, cell, dir) {
    event.cancel();
    if (dir.isDiagonal()) {
      events.fireMessage(cell, "You must use it square on");
      return;
    }
    const sel = player.bag.getSelected?.() ?? player.bag.selected;
    if (sel instanceof Key && sel.color === this.color) {
      TerrainUtils.toggleCellState(cell, this, this.state);
      event.board.fireColorEvent(event, this.color, cell);
      player.bag.remove(sel);
    } else {
      events.fireMessage(cell, "You need the right key");
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, state]) {
      return new KeySwitch(colorByName(color) ?? NONE, stateFromString(state));
    }
    store(ks) {
      return `KeySwitch|${ks.color.name}|${ks.state.name}`;
    }
    example() {
      return new KeySwitch(NONE, OFF);
    }
    template(_id) {
      return "KeySwitch|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * PressurePlate — fires a color event when any agent steps on or off it.
 */
export class PressurePlate extends Terrain {
  constructor(color) {
    super(
      "Pressure Plate",
      TRAVERSABLE | PENETRABLE,
      color,
      Symbol.of("Ξ", NEARBLACK, BLACK, BARELY_BUILDING_WALL, BUILDING_FLOOR),
    );
    this._pressColor = color;
  }
  onEnter(event, _player, cell, _dir) {
    event.board.fireColorEvent(event, this._pressColor, cell);
  }
  onExit(event, _player, cell, _dir) {
    event.board.fireColorEvent(event, this._pressColor, cell);
  }
  onAgentEnter(event, _agent, cell, _dir) {
    if (!event.isCancelled)
      event.board.fireColorEvent(event, this._pressColor, cell);
  }
  onAgentExit(event, _agent, cell, _dir) {
    if (!event.isCancelled)
      event.board.fireColorEvent(event, this._pressColor, cell);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color]) {
      return new PressurePlate(colorByName(color) ?? NONE);
    }
    store(pp) {
      return `PressurePlate|${pp._pressColor.name}`;
    }
    example() {
      return new PressurePlate(NONE);
    }
    template(_id) {
      return "PressurePlate|{color}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Decorative / Interactive ──────────────────────────────────────────────────

/**
 * Altar — cosmetic 3-part piece (WEST bracket, NONE center π, EAST bracket).
 * Pass direction string "west", "none", or "east".
 */
export class Altar extends Terrain {
  constructor(direction) {
    let entity, fg;
    if (direction === WEST || direction.name === "west") {
      entity = "[";
      fg = DARKGRAY;
    } else if (direction === EAST || direction?.name === "east") {
      entity = "]";
      fg = DARKGRAY;
    } else {
      entity = "π";
      fg = LIGHTSLATEGRAY;
    }
    super(
      "Altar",
      TRAVERSABLE | PENETRABLE,
      Symbol.of(entity, fg, DARKSLATEGRAY),
    );
    this._direction = direction;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([direction]) {
      return new Altar(directionByName(direction) ?? DIR_NONE);
    }
    store(a) {
      return `Altar|${a._direction?.name ?? String(a._direction)}`;
    }
    example() {
      return new Altar(DIR_NONE);
    }
    template(_id) {
      return "Altar|{direction}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Bookshelf — impassable; searching gives the stored item.
 * item may be null (empty shelf).
 */
export class Bookshelf extends Terrain {
  static UNMARKED_SYM = Symbol.of(
    "≣",
    BURLYWOOD,
    BLACK,
    BURNTWOOD,
    BUILDING_FLOOR,
  );
  static MARKED_SYM = Symbol.of(
    "≡",
    LIMEGREEN,
    BLACK,
    LIMEGREEN,
    BUILDING_FLOOR,
  );

  constructor(item) {
    super("Bookshelf", Bookshelf.UNMARKED_SYM);
    this.item = item;
  }
  onEnter(event, player, cell, dir) {
    if (dir.isDiagonal()) {
      event.cancelWithMessage("It's a little too far to reach.");
      return;
    }
    event.cancel();
    if (this.item != null) {
      events.fireModalMessage(
        this.item.getIndefiniteNoun("Searching the bookshelf, you find {0}"),
      );
      player.bag.add(this.item);
      // Replace with empty bookshelf
      cell.setTerrain(Registry.get("Bookshelf"));
    } else {
      events.fireMessage(cell, "You find nothing on the bookshelf.");
    }
  }
  onAdjacentTo(event, cell) {
    if (this.item != null && event.player.is(DETECT_HIDDEN)) {
      // Re-render with marked symbol via cell change notification
      this.symbol = Bookshelf.MARKED_SYM;
      cell.board._notifyCellChange(cell);
    }
  }
  onNotAdjacentTo(_event, cell) {
    this.symbol = Bookshelf.UNMARKED_SYM;
    cell.board._notifyCellChange(cell);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new Bookshelf(args.length > 0 ? _ri(args[0]) : null);
    }
    store(b) {
      return b.item ? `Bookshelf|${this.esc(b.item)}` : "Bookshelf";
    }
    example() {
      return new Bookshelf(null);
    }
    template(_id) {
      return "Bookshelf|{item?}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Pit — impassable. Boulders fill it; other items fall in (disappear).
 */
class Pit extends Terrain {
  constructor() {
    super(
      "Pit",
      PENETRABLE,
      Symbol.of("U", NEARBLACK, BLACK, BLACK, BUILDING_FLOOR),
    );
  }
  canEnter(agent, _cell, _direction) {
    return agent instanceof AbstractBoulder;
  }
  onEnter(event, _player, cell, _dir) {
    console.log(_player);
    event.cancelWithMessage(cell, "You'd fall into the pit");
  }
  onAgentEnter(event, agent, cell, dir) {
    if (agent instanceof AbstractBoulder) {
      const prevCell = cell.getAdjacentCell(dir.reverse);
      prevCell?.removeAgent(agent);
      cell.setTerrain(Registry.get("Floor"));
      events.fireMessage(cell, "The pit is filled by the boulder");
    } else if (agent.isSlider || agent.isPusher) {
      const prevCell = cell.getAdjacentCell(dir.reverse);
      prevCell?.removeAgent(agent);
      events.fireMessage(
        cell,
        `The ${agent.name.toLowerCase()} falls through the pit`,
      );
    } else {
      event.cancel();
    }
  }
  onDrop(event, cell, item) {
    if (!(item instanceof Grenade)) {
      event.cancelWithMessage(
        cell,
        item.getDefiniteNoun("{0} falls into the pit"),
      );
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Pit");
    }
    create(_args) {
      return new Pit();
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Pylon ────────────────────────────────────────────────────────────────────

/**
 * Pylon — a color-keyed teleporter. When OFF, can only be activated by
 * consuming a matching Crystal. When ON, teleports the player to the
 * configured destination. Stepping off an inactive pylon auto-activates it,
 * enabling two-way travel with a single crystal.
 */
export class Pylon extends Terrain {
  constructor(color, state, boardID, x, y) {
    super(
      color.name + " Pylon",
      0,
      color,
      Symbol.of("Δ", color, null, color, BUILDING_FLOOR),
    );
    this.state = state;
    this._boardID = boardID;
    this._x = x;
    this._y = y;
  }
  randomSeed() {
    return true;
  }
  onEnter(event, player, cell, dir) {
    if (this.state.isOff()) {
      event.cancel();
      const item = player.bag.getSelected?.();
      if (item instanceof Key && item.color === this.color) {
        event.cancelWithMessage(cell, "Pylons cannot be activated with keys.");
      } else if (item instanceof Crystal && item.color === this.color) {
        player.bag.remove(item);
        TerrainUtils.toggleCellState(cell, this, this.state);
      } else {
        event.cancelWithMessage(cell, "The pylon must be activated.");
      }
    } else if (dir != null) {
      player.teleport(event, dir, this._boardID, this._x, this._y);
    }
  }
  onExit(_event, _player, cell, _dir) {
    if (this.state.isOff()) {
      const other = TerrainUtils.getTerrainOtherState(cell.terrain, this.state);
      cell.setTerrain(other);
    }
  }
  onFrame(_event, cell, frame) {
    if (this.state.isOn()) {
      const outside = cell.board.outside ?? false;
      const bg = cell.terrain.symbol.getBackground(outside);
      const nfg = oscillate(this.color, null, 15, frame);
      const nbg = oscillate(bg, this.color, 15, frame);
      cell._animTerrainSymbol = Symbol.of("Δ", nfg, nbg, nfg, nbg);
      cell.board._notifyCellChange(cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, state, boardID, x, y]) {
      return new Pylon(
        colorByName(color) ?? NONE,
        stateFromString(state),
        boardID,
        parseInt(x) || 0,
        parseInt(y) || 0,
      );
    }
    store(p) {
      return `Pylon|${p.color.name}|${p.state.name}|${p._boardID}|${p._x}|${p._y}`;
    }
    example() {
      return new Pylon(STEELBLUE, OFF, "", 0, 0);
    }
    template(_id) {
      return "Pylon|{color}|{state}|{boardID}|{x}|{y}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/** Resolve an item arg: if it's a plain string key, look it up in the Registry. */
function _ri(arg) {
  return typeof arg === "string" ? Registry.get(arg) : arg;
}

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

export function registerFeaturesTerrain() {
  Registry.register("Door", Door.SERIALIZER);
  Registry.register("Gate", Gate.SERIALIZER);
  Registry.register("RustyGate", RustyGate.SERIALIZER);
  Registry.register("StairsUp", StairsUp.SERIALIZER);
  Registry.register("StairsDown", StairsDown.SERIALIZER);
  Registry.register("CaveEntrance", CaveEntrance.SERIALIZER);
  Registry.register("Boards", Boards.SERIALIZER);
  Registry.register("EmptyChest", EmptyChest.SERIALIZER);
  Registry.register("Chest", Chest.SERIALIZER);
  Registry.register("Crate", Crate.SERIALIZER);
  Registry.register("Urn", Urn.SERIALIZER);
  Registry.register("Switch", Switch.SERIALIZER);
  Registry.register("KeySwitch", KeySwitch.SERIALIZER);
  Registry.register("PressurePlate", PressurePlate.SERIALIZER);
  Registry.register("Altar", Altar.SERIALIZER);
  Registry.register("Bookshelf", Bookshelf.SERIALIZER);
  Registry.register("Pit", Pit.SERIALIZER);
  Registry.register("Pylon", Pylon.SERIALIZER);
}