pieces/terrain/decorators.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 {
  is,
  not,
  getFlag,
  DETECT_HIDDEN,
  PLAYER,
  TRAVERSABLE,
  PENETRABLE,
} from "../../core/flags.js";
import {
  WHITE,
  BLACK,
  colorByName,
  NONE,
  NEARBLACK,
  BARELY_BUILDING_WALL,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { stateFromString } from "../../core/state.js";
import { DOWN } from "../../core/direction.js";

// ── Decorator base class ──────────────────────────────────────────────────────

/**
 * 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 {
  /**
   * @param {Terrain} terrain - wrapped terrain
   * @param {string} name        - override name, or null to inherit from terrain
   * @param {number} flags       - flag mask (use 0 to inherit from terrain)
   * @param {string} color       - color (use null to inherit from terrain)
   * @param {Symbol} symbol  - symbol (use null to inherit)
   */
  constructor(terrain, name, flags, color, symbol) {
    super(name ?? terrain.name, flags, color ?? NONE, symbol ?? terrain.symbol);
    /** @type {Terrain} */
    this.terrain = terrain;
  }

  // Delegate flag checks to underlying terrain
  is(flag) {
    return is(flag, this.terrain.flags);
  }
  not(flag) {
    return not(flag, this.terrain.flags);
  }

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

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

  // ── Full delegation + Internal hooks ─────────────────────────────────────

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

  // ── Internal hooks (no-ops; override in subclasses) ───────────────────────

  onEnterInternal(_event, _player, _cell, _dir) {}
  onExitInternal(_event, _player, _cell, _dir) {}
  onAgentEnterInternal(_event, _agent, _cell, _dir) {}
  onAgentExitInternal(_event, _agent, _cell, _dir) {}
  onFlyOverInternal(_event, _cell, _flier) {}
  onDropInternal(_event, _cell, _item) {}
  onPickupInternal(_event, _loc, _agent, _item) {}
  onAdjacentToInternal(_event, _cell) {}
  onNotAdjacentToInternal(_event, _cell) {}
  onColorEventInternal(_event, _color, _cell) {}
}

// ── DualTerrain ───────────────────────────────────────────────────────────────

/**
 * 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 {
  constructor(terrain1, terrain2, state, color) {
    const active = state.isOn() ? terrain1 : terrain2;
    super(active.name, active.flags, color ?? NONE, active.symbol);
    this.terrain1 = terrain1;
    this.terrain2 = terrain2;
    this.state = state;
    this._color = color;
  }

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

  is(flag) {
    return is(flag, this._activeTerrain.flags);
  }
  not(flag) {
    return not(flag, this._activeTerrain.flags);
  }
  getProxiedTerrain() {
    return this._activeTerrain;
  }
  get name() {
    return this._activeTerrain.name;
  }
  set name(_) {}
  get symbol() {
    return this._activeTerrain.symbol;
  }
  set symbol(_) {}

  onColorEvent(event, color, cell) {
    if (color === this._color) {
      TerrainUtils.toggleCellState(cell, this, this.state);
    }
  }

  canEnter(a, c, d) {
    return this._activeTerrain.canEnter(a, c, d);
  }
  canExit(a, c, d) {
    return this._activeTerrain.canExit(a, c, d);
  }
  onEnter(e, p, c, d) {
    this._activeTerrain.onEnter(e, p, c, d);
  }
  onExit(e, p, c, d) {
    this._activeTerrain.onExit(e, p, c, d);
  }
  onAgentEnter(e, a, c, d) {
    this._activeTerrain.onAgentEnter(e, a, c, d);
  }
  onAgentExit(e, a, c, d) {
    this._activeTerrain.onAgentExit(e, a, c, d);
  }
  onFlyOver(e, c, f) {
    this._activeTerrain.onFlyOver(e, c, f);
  }
  onDrop(e, c, i) {
    this._activeTerrain.onDrop(e, c, i);
  }
  onPickup(e, l, a, i) {
    this._activeTerrain.onPickup(e, l, a, i);
  }
  onAdjacentTo(e, c) {
    this._activeTerrain.onAdjacentTo(e, c);
  }
  onNotAdjacentTo(e, c) {
    this._activeTerrain.onNotAdjacentTo(e, c);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([t1, t2, state, color]) {
      return new DualTerrain(
        _rt(t1),
        _rt(t2),
        stateFromString(state),
        colorByName(color) ?? NONE,
      );
    }
    store(d) {
      return `DualTerrain|${this.esc(d.terrain1)}|${this.esc(d.terrain2)}|${d.state.name}|${d._color.name}`;
    }
    example() {
      return new DualTerrain(
        Registry.get("Floor"),
        Registry.get("Wall"),
        stateFromString("on"),
        NONE,
      );
    }
    template(_id) {
      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 {
  constructor(terrain, message) {
    super(
      terrain,
      "Sign",
      terrain.flags,
      NONE,
      Symbol.of(
        "⌂",
        WHITE,
        terrain.symbol.getBackground(false),
        BLACK,
        terrain.symbol.getBackground(true),
      ),
    );
    this.message = message;
  }
  onEnterInternal(_event, _player, cell, _dir) {
    events.fireMessage(cell, this.message);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, message]) {
      return new Sign(_rt(terrain), message);
    }
    store(s) {
      return `Sign|${this.esc(s.terrain)}|${s.message}`;
    }
    example() {
      return new Sign(Registry.get("Floor"), "Hello!");
    }
    template(_id) {
      return "Sign|{terrain}|{message}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

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

// ── Simple color-event decorators ─────────────────────────────────────────────

/** Adds a flag to the player on color event. */
export class Flagger extends Decorator {
  constructor(terrain, color, flagStr) {
    super(terrain, null, 0, color, null);
    this.flagStr = flagStr;
  }
  onColorEventInternal(event, color, _cell) {
    if (color === this.color) {
      const flag = getFlagByLabel(this.flagStr);
      if (flag && event.player) event.player.flags |= flag;
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, flagStr]) {
      return new Flagger(_rt(terrain), colorByName(color) ?? NONE, flagStr);
    }
    store(f) {
      return `Flagger|${this.esc(f.terrain)}|${f.color.name}|${f.flagStr}`;
    }
    example() {
      return new Flagger(Registry.get("Floor"), NONE, "poisoned");
    }
    template(_id) {
      return "Flagger|{terrain}|{color}|{item}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Removes a flag from the player on color event. */
export class Unflagger extends Decorator {
  constructor(terrain, color, flagStr) {
    super(terrain, null, 0, color, null);
    this.flagStr = flagStr;
  }
  onColorEventInternal(event, color, _cell) {
    if (color === this.color) {
      const flag = getFlagByLabel(this.flagStr);
      if (flag && event.player) event.player.flags &= ~flag;
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, flagStr]) {
      return new Unflagger(_rt(terrain), colorByName(color) ?? NONE, flagStr);
    }
    store(f) {
      return `Unflagger|${this.esc(f.terrain)}|${f.color.name}|${f.flagStr}`;
    }
    example() {
      return new Unflagger(Registry.get("Floor"), NONE, "poisoned");
    }
    template(_id) {
      return "Unflagger|{terrain}|{color}|{item}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Shows a modal message on color event. */
export class Messenger extends Decorator {
  constructor(terrain, color, message) {
    super(terrain, null, 0, color, null);
    this.message = message;
  }
  onColorEventInternal(_event, color, _cell) {
    if (color === this.color) events.fireModalMessage(this.message);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, message]) {
      return new Messenger(_rt(terrain), colorByName(color) ?? NONE, message);
    }
    store(m) {
      return `Messenger|${this.esc(m.terrain)}|${m.color.name}|${m.message}`;
    }
    example() {
      return new Messenger(Registry.get("Floor"), NONE, "Hello!");
    }
    template(_id) {
      return "Messenger|{terrain}|{color}|{message}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Adds an item to the player's bag on color event. */
export class Equipper extends Decorator {
  constructor(terrain, color, item) {
    super(terrain, null, 0, color, null);
    this.item = item;
  }
  onColorEventInternal(event, color, _cell) {
    if (color === this.color && event.player) event.player.bag.add(this.item);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, item]) {
      return new Equipper(_rt(terrain), colorByName(color) ?? NONE, _rt(item));
    }
    store(e) {
      return `Equipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
    }
    example() {
      return null;
    }
    template(_id) {
      return "Equipper|{terrain}|{color}|{item}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Removes an item from the player's bag on color event. */
export class Unequipper extends Decorator {
  constructor(terrain, color, item) {
    super(terrain, null, 0, color, null);
    this.item = item;
  }
  onColorEventInternal(event, color, _cell) {
    if (color === this.color && event.player)
      event.player.bag.remove(this.item);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, item]) {
      return new Unequipper(
        _rt(terrain),
        colorByName(color) ?? NONE,
        _rt(item),
      );
    }
    store(e) {
      return `Unequipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
    }
    example() {
      return null;
    }
    template(_id) {
      return "Unequipper|{terrain}|{color}|{item}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Fires a different color event when it receives its own color event (multiplexing). */
export class ColorRelay extends Decorator {
  constructor(terrain, color, relayTo) {
    super(terrain, null, 0, color, null);
    this.relayTo = relayTo;
  }
  onColorEventInternal(event, color, cell) {
    if (color === this.color)
      event.board.fireColorEvent(event, this.relayTo, cell);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, relayTo]) {
      return new ColorRelay(
        _rt(terrain),
        colorByName(color) ?? NONE,
        colorByName(relayTo) ?? NONE,
      );
    }
    store(cr) {
      return `ColorRelay|${this.esc(cr.terrain)}|${cr.color.name}|${cr.relayTo.name}`;
    }
    example() {
      return new ColorRelay(Registry.get("Floor"), NONE, NONE);
    }
    template(_id) {
      return "ColorRelay|{terrain}|{fromColor}|{toColor}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Ends the game with a victory screen URL when it receives its color event. */
export class WinGame extends Decorator {
  constructor(terrain, color, url) {
    super(terrain, null, 0, color, null);
    this.url = url;
  }
  onColorEventInternal(event, color, _cell) {
    if (color === this.color) {
      events.fireModalMessage(`You win! ${this.url}`);
      // In a full implementation this would navigate to the win screen.
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, url]) {
      return new WinGame(_rt(terrain), colorByName(color) ?? NONE, url);
    }
    store(w) {
      return `WinGame|${this.esc(w.terrain)}|${w.color.name}|${w.url}`;
    }
    example() {
      return new WinGame(Registry.get("Floor"), NONE, "win.html");
    }
    template(_id) {
      return "WinGame|{terrain}|{color}|{url}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Creates a piece (item or agent) at the decorator cell or origin on color event. */
export class PieceCreator extends Decorator {
  constructor(terrain, color, piece, atOrigin) {
    super(terrain, null, 0, color, null);
    this.piece = piece;
    this.atOrigin = atOrigin;
  }
  onColorEventInternal(event, color, cell) {
    if (color !== this.color) return;
    // Place item or agent at this cell (or origin cell if atOrigin=true)
    const target = this.atOrigin ? (event._originCell ?? cell) : cell;
    if (this.piece?.isItem) target.addItem(this.piece);
    else if (this.piece && target.agent == null) target.setAgent(this.piece);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color, piece, atOrigin]) {
      return new PieceCreator(
        _rt(terrain),
        colorByName(color) ?? NONE,
        piece,
        atOrigin === "true",
      );
    }
    store(pc) {
      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 null;
    }
    template(_id) {
      return "PieceCreator|{terrain}|{color}|{piece}|{atOrigin}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Destroys any agent that enters, or destroys agents on color event. */
export class AgentDestroyer extends Decorator {
  constructor(terrain, color) {
    super(terrain, null, 0, color, null);
  }
  onAgentEnterInternal(_event, agent, cell, _dir) {
    if (!this.color || this.color === NONE) {
      cell.removeAgent(agent);
    }
  }
  onColorEventInternal(event, color, cell) {
    if (color === this.color && cell.agent) {
      cell.removeAgent(cell.agent);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain, color]) {
      return new AgentDestroyer(_rt(terrain), colorByName(color) ?? NONE);
    }
    store(ad) {
      return `AgentDestroyer|${this.esc(ad.terrain)}|${ad.color.name}`;
    }
    example() {
      return new AgentDestroyer(Registry.get("Floor"), NONE);
    }
    template(_id) {
      return "AgentDestroyer|{terrain}|{color}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Blocks players from entering unless they have a specific flag or item. */
export class PlayerGate extends Decorator {
  constructor(terrain, flagStr, message) {
    super(terrain, null, 0, NONE, null);
    this.flagStr = flagStr;
    this.message = message ?? null;
  }
  onEnterInternal(event, player, cell, _dir) {
    if (event.isCancelled) return;
    const hasFlag = checkPlayerHas(player, this.flagStr);
    if (!hasFlag) {
      if (this.message) events.fireModalMessage(this.message);
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new PlayerGate(_rt(args[0]), args[1], args[2] ?? null);
    }
    store(pg) {
      return pg.message
        ? `PlayerGate|${this.esc(pg.terrain)}|${pg.flagStr}|${pg.message}`
        : `PlayerGate|${this.esc(pg.terrain)}|${pg.flagStr}`;
    }
    example() {
      return new PlayerGate(Registry.get("Floor"), "poisoned", null);
    }
    template(_id) {
      return "PlayerGate|{terrain}|{flag}|{message?}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Blocks all non-player agents from entering or exiting. */
export class AgentGate extends Decorator {
  constructor(terrain) {
    super(terrain, null, 0, NONE, null);
  }
  canEnter(agent, cell, direction) {
    if (is(PLAYER, agent.flags))
      return this.terrain.canEnter(agent, cell, direction);
    return false;
  }
  canExit(agent, cell, direction) {
    if (is(PLAYER, agent.flags))
      return this.terrain.canExit(agent, cell, direction);
    return false;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]) {
      return new AgentGate(_rt(terrain));
    }
    store(ag) {
      return `AgentGate|${this.esc(ag.terrain)}`;
    }
    example() {
      return new AgentGate(Registry.get("Floor"));
    }
    template(_id) {
      return "AgentGate|{terrain}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** Mimic — appears as one terrain but behaves as another. */
export class Mimic extends Decorator {
  /**
   * @param {Terrain} appearsAs - visual terrain
   * @param {Terrain} actual    - behavioral terrain
   * @param {string} color  - trigger color
   */
  constructor(appearsAs, actual, color) {
    super(actual, null, 0, color, appearsAs.symbol);
    this.appearsAs = appearsAs;
  }
  getProxiedTerrain() {
    return this.terrain;
  }
  getApparentTerrain() {
    return this.appearsAs;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([appearsAs, actual, color]) {
      return new Mimic(_rt(appearsAs), _rt(actual), colorByName(color) ?? NONE);
    }
    store(m) {
      return `Mimic|${this.esc(m.appearsAs)}|${this.esc(m.terrain)}|${m.color.name}`;
    }
    example() {
      return null;
    }
    template(_id) {
      return "Mimic|{appearsAsTerrain}|{actualTerrain}|{color}";
    }
    tag() {
      return "Utility Terrain";
    }
  })();
}

/** SecretPassage — looks like Wall but is traversable like Floor. */
export class SecretPassage extends Mimic {
  constructor(wall, floor) {
    super(wall, floor, NONE);
  }
  canEnter(agent, cell, 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(_args) {
      return new SecretPassage(Registry.get("Wall"), Registry.get("Floor"));
    }
    tag() {
      return "Terrain";
    }
  })();
}

/** PitTrap — looks like Floor but drops the player into a Pit. */
export class PitTrap extends Mimic {
  constructor(floor, pit) {
    super(floor, 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, cell, 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, player, cell, _dir) {
    events.fireMessage(cell, "You fall into a pit!");
    player.changeHealth(20);
    // Schedule terrain reveal and fall-through teleport after a brief delay,
    // matching Java's Timer.schedule(200) pattern.
    setTimeout(() => {
      cell.setTerrain(this.terrain); // reveal the pit
      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, 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 boulder fills a hidden pit!");
    } else if (agent.isSlider || agent.isPusher) {
      const prevCell = cell.getAdjacentCell(dir.reverse);
      prevCell?.removeAgent(agent);
      cell.setTerrain(this.terrain); // reveal the pit
      events.fireMessage(
        cell,
        `The ${agent.name.toLowerCase()} falls through a hidden pit`,
      );
    }
    // Regular agents walk right over hidden pits (no cancel, no action).
  }
  onAdjacentTo(event, cell) {
    if (event.player.is(DETECT_HIDDEN)) {
      cell._animTerrainSymbol = Registry.get("Pit").symbol;
      cell.board._notifyCellChange(cell);
    }
  }
  onNotAdjacentTo(_event, cell) {
    cell._animTerrainSymbol = null;
    cell.board._notifyCellChange(cell);
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("PitTrap");
    }
    create(_args) {
      return new PitTrap(Registry.get("Floor"), Registry.get("Pit"));
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Trapped containers ────────────────────────────────────────────────────────

/** Base for containers that release a cloud when opened. */
class TrapContainerBase extends Decorator {
  constructor(terrain, cloudType) {
    super(terrain, null, 0, NONE, null);
    this.cloudType = cloudType;
  }
  onEnterInternal(event, player, cell, _dir) {
    if (event.isCancelled) return;
    // Spawn cloud effect
    const cloudKey = this.cloudType;
    try {
      const cloud = Registry.get(cloudKey);
      cell.addEffect(cloud);
    } catch (_) {
      /* cloud not yet registered */
    }
  }
}

/** Energy trap container */
export class EnergyTrapContainer extends TrapContainerBase {
  constructor(terrain) {
    super(terrain, "EnergyCloud");
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]) {
      return new EnergyTrapContainer(_rt(terrain));
    }
    store(tc) {
      return `EnergyTrapContainer|${this.esc(tc.terrain)}`;
    }
    example() {
      return new EnergyTrapContainer(Registry.get("Floor"));
    }
    template(_id) {
      return "EnergyTrapContainer|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}
/** Poison trap container */
export class PoisonTrapContainer extends TrapContainerBase {
  constructor(terrain) {
    super(terrain, "PoisonCloud");
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]) {
      return new PoisonTrapContainer(_rt(terrain));
    }
    store(tc) {
      return `PoisonTrapContainer|${this.esc(tc.terrain)}`;
    }
    example() {
      return new PoisonTrapContainer(Registry.get("Floor"));
    }
    template(_id) {
      return "PoisonTrapContainer|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}
/** Resistances trap container */
export class ResistancesTrapContainer extends TrapContainerBase {
  constructor(terrain) {
    super(terrain, "ResistancesCloud");
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]) {
      return new ResistancesTrapContainer(_rt(terrain));
    }
    store(tc) {
      return `ResistancesTrapContainer|${this.esc(tc.terrain)}`;
    }
    example() {
      return new ResistancesTrapContainer(Registry.get("Floor"));
    }
    template(_id) {
      return "ResistancesTrapContainer|{terrain}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

// ── Cliff ─────────────────────────────────────────────────────────────────────

/**
 * Cliff — decorator that enforces directional cliff traversal.
 * Agents can only enter a Cliff cell from a non-cliff side, and only exit
 * to a non-cliff side. The player gets a "too steep" message otherwise.
 */
export class Cliff extends Decorator {
  constructor(terrain) {
    super(terrain, null, 0, NONE, null);
  }
  proxy(terrain) {
    return new Cliff(terrain);
  }
  canEnter(agent, cell, dir) {
    if (!super.canEnter(agent, cell, dir)) {
      return false;
    }
    const behind = cell.getAdjacentCell?.(dir?.reverse);
    if (!behind) {
      return true;
    }
    return behind.getApparentTerrain().name === this.terrain.name;
  }
  canExit(agent, cell, dir) {
    if (!super.canExit(agent, cell, dir)) {
      return false;
    }
    const ahead = cell.getAdjacentCell(dir);
    if (!ahead) {
      return true;
    }
    return ahead.getApparentTerrain().name === this.terrain.name;
  }
  onEnterInternal(event, _player, cell, dir) {
    const behind = cell.getAdjacentCell(dir.reverse);
    if (behind.getApparentTerrain().name !== this.terrain.name) {
      events.fireMessage(cell, "It's too steep to climb up here.");
      event.cancel();
    }
  }
  onExitInternal(event, _player, cell, dir) {
    const ahead = cell.getAdjacentCell?.(dir);
    if (ahead.getApparentTerrain().name !== this.terrain.name) {
      events.fireMessage(cell, "It's too steep to climb down here.");
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([terrain]) {
      return new Cliff(_rt(terrain));
    }
    store(c) {
      return `Cliff|${this.esc(c.terrain)}`;
    }
    example() {
      return new Cliff(Registry.get("Floor"));
    }
    template(_id) {
      return "Cliff|{terrain}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

// ── Timer ─────────────────────────────────────────────────────────────────────

/**
 * Timer — decorator that fires a color event every N frames.
 * The color event is broadcast to the board.
 */
export class Timer extends Decorator {
  constructor(terrain, color, frames) {
    super(terrain, null, 0, color, null);
    this._frames = frames > 0 ? frames : 1;
  }
  proxy(terrain) {
    return new Timer(terrain, this.color, this._frames);
  }
  // onFrame is called by the AnimationManager each tick
  onFrame(_ctx, cell, frame) {
    if (frame > 0 && frame % this._frames === 0) {
      cell.board.fireColorEvent(null, this.color, cell);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new Timer(
        _rt(args[0]),
        colorByName(args[1]) ?? NONE,
        parseInt(args[2]) || 1,
      );
    }
    store(t) {
      return `Timer|${this.esc(t.terrain)}|${t.color.name}|${t._frames}`;
    }
    example() {
      return new Timer(Registry.get("Floor"), NONE, 10);
    }
    template(_id) {
      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("WinGame", WinGame.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);

  // AgentCreator|{terrain}|{color}|{agentKey}
  // Creates the named agent at this cell on color event.
  Registry.register("AgentCreator", (args) => {
    const terrain = _rt(args[0]);
    const color = colorByName(args[1]) ?? NONE;
    const agent = typeof args[2] === "string" ? _tryGetPiece(args[2]) : args[2];
    return new PieceCreator(terrain, color, agent, false);
  });
}

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

/** Check if a player has a given flag (by label) or item (by name). */
function checkPlayerHas(player, flagStr) {
  if (!player || !flagStr) {
    return false;
  }
  const flag = getFlag(flagStr);
  if (flag !== -1 && player.is(flag)) {
    return true;
  }
  return player.bag.find((i) => i.name === flagStr) != null;
}

/** Resolve a terrain arg that may be a plain string key or an already-resolved Piece. */
function _rt(arg) {
  return typeof arg === "string" ? Registry.get(arg) : arg;
}

/** Look up a flag bitmask by its display label, returning 0 if unknown. */
function getFlagByLabel(label) {
  const v = getFlag(label);
  return v === -1 ? 0 : v;
}

/** Try to get a piece from the registry; returns null if not found. */
function _tryGetPiece(key) {
  try {
    return Registry.get(key);
  } catch (_) {
    return null;
  }
}