pieces/terrain/basic.js

import { Terrain } from "../../core/terrain.js";
import { Registry } from "../../core/registry.js";
import {
  EuclideanShard,
  FishingPole,
  GoldCoin,
  Grenade,
  Rock,
} from "../../pieces/items/items.js";
import { TerrainUtils } from "../../core/terrain-utils.js";
import { events } from "../../core/events.js";
import { ImmobileAgent } from "../../pieces/agents/creatures.js";
import {
  TRAVERSABLE,
  PENETRABLE,
  ETHEREAL,
  HIDES_ITEMS,
  AMMO_ENLIVENER,
  AQUATIC,
  POISONED,
  WEAK,
} from "../../core/flags.js";
import {
  BARELY_BUILDING_WALL,
  BLACK,
  BUILDING_FLOOR,
  BUILDING_WALL,
  BURLYWOOD,
  BURNTWOOD,
  BUSHES,
  CLIFFS,
  DARK_PIER,
  DARKGOLDENROD,
  DARKKHAKI,
  DARKSLATEBLUE,
  DARKSLATEGRAY,
  FOREST,
  FORESTGREEN,
  GHOSTWHITE,
  GOLD,
  GOLDENROD,
  GRASS,
  HIGH_ROCKS,
  LESSNEARBLACK,
  LIGHTSLATEGRAY,
  LIGHTSTEELBLUE,
  LOW_ROCKS,
  NEARBLACK,
  NONE,
  OCEAN,
  ORANGE,
  PIER,
  RED,
  SALMON,
  SAND,
  SIENNA,
  SILVER,
  SKYBLUE,
  SURF,
  TAN,
  VERYBLACK,
  VIOLET,
  WHITE,
  WOOD_PILING,
  YELLOW,
  colorByName,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { stateFromString, ON, OFF } from "../../core/state.js";
import {
  directionByName,
  NORTH,
  SOUTH,
  EAST,
  WEST,
  NORTHEAST,
  NONE as DIR_NONE,
} from "../../core/direction.js";
import { game } from "../../core/game.js";
import { getRandomDirection } from "../agents/targeting.js";

const EMPTY_HANDED = "Empty-handed";

// ── Basic / Stateless Terrain ─────────────────────────────────────────────────

class Floor extends Terrain {
  constructor() {
    super(
      "Floor",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("\u2003", VERYBLACK, null, BLACK, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Floor");
    }
    create(_args) {
      return new Floor();
    }
    tag() {
      return "Terrain";
    }
  })();
}

class Wall extends Terrain {
  constructor() {
    super("Wall", Symbol.of("\u2003", null, NEARBLACK, null, BUILDING_WALL));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Wall");
    }
    create(_args) {
      return new Wall();
    }
    tag() {
      return "Terrain";
    }
  })();
}

class Dirt extends Terrain {
  constructor() {
    super("Dirt", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, TAN));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Dirt");
    }
    create(_args) {
      return new Dirt();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class Field extends Terrain {
  constructor() {
    super("Field", TRAVERSABLE | PENETRABLE, Symbol.of("„", FORESTGREEN, TAN));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Field");
    }
    create(_args) {
      return new Field();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class Forest extends Terrain {
  constructor() {
    super(
      "Forest",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("\u2003", null, FOREST),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Forest");
    }
    create(_args) {
      return new Forest();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Crevasse — an impassable chasm. Appears as a plain black cell until the
 * player is adjacent, at which point it reveals itself as "≈" on a very-dark
 * background. Has no outside representation (looks the same in both modes).
 */
class Crevasse extends Terrain {
  static REVEALED = Symbol.of("\u2248", BLACK, VERYBLACK);
  static NOT_REVEALED = Symbol.of("\u2003", NONE, BLACK);

  constructor() {
    super("Crevasse", ETHEREAL | PENETRABLE, Crevasse.NOT_REVEALED);
  }
  onEnter(event, _player, cell, _dir) {
    event.cancelWithMessage(cell, "A deep crevasse spans before you");
  }
  onAdjacentTo(_event, cell) {
    cell._animTerrainSymbol = Crevasse.REVEALED;
    cell.board._notifyCellChange(cell);
  }
  onNotAdjacentTo(_event, cell) {
    cell._animTerrainSymbol = null;
    cell.board._notifyCellChange(cell);
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Crevasse");
    }
    create(_args) {
      return new Crevasse();
    }
    tag() {
      return "Terrain";
    }
  })();
}

/** Sky — ethereal: only fliers can move here, but items pass through */
class Sky extends Terrain {
  constructor() {
    super("Sky", ETHEREAL | PENETRABLE, Symbol.of("\u2003", null, SKYBLUE));
  }
  onDrop(event, cell, _item) {
    event.cancel();
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Sky");
    }
    create(_args) {
      return new Sky();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** Cloud — traversable; items cannot be dropped here */
class Cloud extends Terrain {
  constructor() {
    super(
      "Cloud",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("\u2003", null, GHOSTWHITE),
    );
  }
  onDrop(event, cell, _item) {
    event.cancel();
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Cloud");
    }
    create(_args) {
      return new Cloud();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class ChalkedFloor extends Terrain {
  constructor() {
    super(
      "Chalked Floor",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("✗", LESSNEARBLACK, null, NEARBLACK, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("ChalkedFloor");
    }
    create(_args) {
      return new ChalkedFloor();
    }
    tag() {
      return "Terrain";
    }
  })();
}

/** TrashPile — HIDES_ITEMS: items are not visible until you step on it */
class TrashPile extends Terrain {
  constructor() {
    super(
      "Trash Pile",
      TRAVERSABLE | PENETRABLE | HIDES_ITEMS,
      Symbol.of("%", SIENNA, BUILDING_FLOOR, BLACK, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("TrashPile");
    }
    create(_args) {
      return new TrashPile();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/** Haystack — HIDES_ITEMS, with outdoor color variant */
class Haystack extends Terrain {
  constructor() {
    super(
      "Haystack",
      TRAVERSABLE | PENETRABLE | HIDES_ITEMS,
      Symbol.of("λ", GOLDENROD, DARKGOLDENROD, BLACK, TAN),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Haystack");
    }
    create(_args) {
      return new Haystack();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class Pier extends Terrain {
  constructor() {
    super("Pier", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, PIER));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Pier");
    }
    create(_args) {
      return new Pier();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class WoodPiling extends Terrain {
  constructor() {
    super("Wood Piling", Symbol.of("\u2003", null, WOOD_PILING));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("WoodPiling");
    }
    create(_args) {
      return new WoodPiling();
    }
    tag() {
      return "Terrain";
    }
  })();
}

class Sand extends Terrain {
  constructor() {
    super("Sand", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, SAND));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Sand");
    }
    create(_args) {
      return new Sand();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class LowRocks extends Terrain {
  constructor() {
    super(
      "Low Rocks",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("\u2003", null, LOW_ROCKS),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("LowRocks");
    }
    create(_args) {
      return new LowRocks();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** HighRocks — TRAVERSABLE; blocks in-flight items (arrows etc.) */
class HighRocks extends Terrain {
  constructor() {
    super(
      "High Rocks",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("⛰", HIGH_ROCKS, LOW_ROCKS), // alt: ^
    );
  }
  onFlyOver(event, _cell, _flier) {
    event.cancel();
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("HighRocks");
    }
    create(_args) {
      return new HighRocks();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** ImpassableCliffs — totally impassable rock face */
class ImpassableCliffs extends Terrain {
  constructor() {
    super("Impassable Cliffs", Symbol.of("\u0394", BLACK, HIGH_ROCKS));
  }
  onEnter(event, _player, cell, _dir) {
    events.fireMessage(cell, "The rocks are too steep to climb");
    event.cancel();
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("ImpassableCliffs");
    }
    create(_args) {
      return new ImpassableCliffs();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** PyramidWall — impassable, tinted with a designer-chosen color */
export class PyramidWall extends Terrain {
  constructor(color) {
    super(
      color.name + " Pyramid Wall",
      0,
      color,
      Symbol.of("\u2003", null, color),
    );
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color]) {
      return new PyramidWall(colorByName(color) ?? NONE);
    }
    store(pw) {
      return `PyramidWall|${pw.color.name}`;
    }
    example() {
      return new PyramidWall(NONE);
    }
    template(_id) {
      return "PyramidWall|{color}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** Fence — purely cosmetic barrier (PENETRABLE only, no TRAVERSABLE) */
class Fence extends ImmobileAgent {
  constructor(direction) {
    if (direction != NORTH && direction != EAST && direction != DIR_NONE) {
      throw new Error("Fence direction must be north, east or none.");
    }
    const symbol = Symbol.of(
      direction == EAST ? "=" : direction == NORTH ? "I" : "‡",
      BURLYWOOD,
      null,
      BURNTWOOD,
      null,
    );
    super("Fence", PENETRABLE, symbol);
    this.direction = direction;
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir]) {
      return new Fence(directionByName(dir));
    }
    store(f) {
      return `Fence|${f.direction?.name ?? f.direction}`;
    }
    example() {
      return new Fence(NORTH);
    }
    template(_id) {
      return "Fence|{direction}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class Boards extends Terrain {
  constructor() {
    super(
      "Boards",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("≠", BARELY_BUILDING_WALL, BUILDING_FLOOR),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Boards");
    }
    create(_args) {
      return new Boards();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Bridge — only N/S/E/W movement allowed; diagonal and vertical blocked.
 * The background mirrors the terrain below it (water, lava, etc.).
 */
export class Bridge extends Terrain {
  /** @param {string} terrainBg  background color of underlying terrain */
  constructor(terrainBg) {
    super(
      "Bridge",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("\u2261", DARK_PIER, terrainBg),
    );
    this._terrainBg = terrainBg;
  }
  canEnter(_agent, _cell, direction) {
    return !direction.isDiagonal() && !direction.isVertical();
  }
  canExit(_agent, _cell, direction) {
    return !direction.isDiagonal() && !direction.isVertical();
  }
  onEnter(event, _player, _cell, dir) {
    if (dir.isDiagonal() || dir.isVertical()) event.cancel();
  }
  onExit(event, _player, _cell, dir) {
    if (dir.isDiagonal() || dir.isVertical()) event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color]) {
      return new Bridge(colorByName(color) ?? OCEAN);
    }
    store(b) {
      return `Bridge|${b._terrainBg.name}`;
    }
    example() {
      return new Bridge(OCEAN);
    }
    template(_id) {
      return "Bridge|{color}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** Flowers — decorative ground cover tinted with a color */
export class Flowers extends Terrain {
  constructor(color) {
    super(
      color.name + " Flowers",
      TRAVERSABLE | PENETRABLE,
      color,
      Symbol.of("⚘" /*"ϊ"*/, color, GRASS),
    );
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color]) {
      return new Flowers(colorByName(color) ?? NONE);
    }
    store(f) {
      return `Flowers|${f.color.name}`;
    }
    example() {
      return new Flowers(NONE);
    }
    template(_id) {
      return "Flowers|{color}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

// ── Grasses ───────────────────────────────────────────────────────────────────

class Grass extends Terrain {
  constructor() {
    super("Grass", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, GRASS));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Grass");
    }
    create(_args) {
      return new Grass();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

class TallGrass extends Terrain {
  constructor() {
    super(
      "Tall Grass",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("…", BUSHES, GRASS),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("TallGrass");
    }
    create(_args) {
      return new TallGrass();
    }
    tag() {
      return "Grasses";
    }
  })();
}

class BunchGrass extends Terrain {
  constructor() {
    super(
      "Bunch Grass",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("…", FOREST, GRASS),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("BunchGrass");
    }
    create(_args) {
      return new BunchGrass();
    }
    tag() {
      return "Grasses";
    }
  })();
}

class BeachGrass extends Terrain {
  constructor() {
    super(
      "Beach Grass",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("…", DARKKHAKI, SAND),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("BeachGrass");
    }
    create(_args) {
      return new BeachGrass();
    }
    tag() {
      return "Grasses";
    }
  })();
}

class SwampGrass extends Terrain {
  constructor() {
    super(
      "Swamp Grass",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("…", GRASS, BUSHES),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("SwampGrass");
    }
    create(_args) {
      return new SwampGrass();
    }
    tag() {
      return "Grasses";
    }
  })();
}

class Scrub extends Terrain {
  constructor() {
    super("Scrub", TRAVERSABLE | PENETRABLE, Symbol.of("…", BUSHES, SAND));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Scrub");
    }
    create(_args) {
      return new Scrub();
    }
    tag() {
      return "Grasses";
    }
  })();
}

class Weeds extends Terrain {
  constructor() {
    super("Weeds", TRAVERSABLE | PENETRABLE, Symbol.of("…", GRASS, TAN));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Weeds");
    }
    create(_args) {
      return new Weeds();
    }
    tag() {
      return "Grasses";
    }
  })();
}

// ── New Terrain ───────────────────────────────────────────────────────────────

/** Bushes — traversable outdoor ground cover */
class Bushes extends Terrain {
  constructor() {
    super(
      "Bushes",
      PENETRABLE | TRAVERSABLE,
      Symbol.of("\u2003", null, BUSHES),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Bushes");
    }
    create(_args) {
      return new Bushes();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Throne — directional seat; only enterable from the facing direction
 * and only exitable in the facing direction.
 */
export class Throne extends Terrain {
  constructor(direction) {
    super(
      "Throne",
      TRAVERSABLE | PENETRABLE,
      Symbol.of(
        direction === NORTH ? "◛" : "◚",
        SILVER,
        BLACK,
        BUILDING_WALL,
        BUILDING_FLOOR,
      ),
    );
    this._direction = direction;
  }
  onEnter(event, _player, _cell, dir) {
    const required = this._direction === NORTH ? SOUTH : NORTH;
    if (dir !== required) event.cancel();
  }
  onExit(event, _player, _cell, dir) {
    if (dir !== this._direction) event.cancel();
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir]) {
      return new Throne(directionByName(dir) ?? NORTH);
    }
    store(t) {
      return `Throne|${t._direction.name}`;
    }
    example() {
      return new Throne(NORTH);
    }
    template(_id) {
      return "Throne|{direction}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * WishingWell — drop a GoldCoin to get healed. Wraps underlying terrain.
 */
export class WishingWell extends Terrain {
  constructor(terrain, state) {
    super(
      "Well",
      PENETRABLE,
      Symbol.of("Ф", OCEAN, terrain.symbol.getBackground(false)),
    );
    this._terrain = terrain;
    this._state = state;
  }
  onDrop(event, cell, item) {
    if (!(item instanceof Grenade)) {
      event.cancelWithMessage(
        cell,
        item?.getDefiniteNoun("{0} falls into the well") ??
          "It falls into the well",
      );
    }
  }
  onEnter(event, player, cell, _dir) {
    event.cancel();
    const sel = player.bag?.getSelected?.() ?? player.bag?.selected;
    if (sel instanceof GoldCoin) {
      player.bag.remove(sel);
      if (this._state.isOn()) {
        events.fireMessage(cell, "You toss a coin into the well.");
        _wishingWellHeal(player, cell, this, this._state);
      } else {
        events.fireMessage(
          cell,
          "You toss a coin into the well. Nothing happens.",
        );
      }
    } else if (sel instanceof Rock) {
      events.fireMessage(
        cell,
        "You toss a rock into the well. Calming, isn't it?",
      );
    } else if (sel && sel.name !== EMPTY_HANDED) {
      events.fireMessage(cell, "Try a coin...");
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new WishingWell(_rt(args[0]), stateFromString(args[1]) ?? ON);
    }
    store(w) {
      return `WishingWell|${this.esc(w._terrain)}|${w._state.name}`;
    }
    example() {
      return new WishingWell(new Floor(), ON);
    }
    template(_id) {
      return "WishingWell|{terrain}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

function _wishingWellHeal(player, cell, well, state) {
  if (player.is(POISONED)) {
    player.remove(POISONED);
    events.fireMessage(cell, "You are no longer poisoned.");
    TerrainUtils.toggleCellState(cell, well, state);
  } else if (player.is(WEAK)) {
    player.remove(WEAK);
    events.fireMessage(cell, "You no longer feel weak.");
    TerrainUtils.toggleCellState(cell, well, state);
  } else {
    const hp = player.changeHealth(0);
    if (hp < 100) {
      player.changeHealth(hp - 100);
      events.fireMessage(cell, "You feel fully healed.");
      TerrainUtils.toggleCellState(cell, well, state);
    } else {
      events.fireMessage(cell, "Nothing happens.");
    }
  }
}

/**
 * Teleporter — transports the player to a specific board/x/y on entry.
 * Animates through RED→SALMON→ORANGE→WHITE cycling (mirrors Java Animated impl).
 */
export class Teleporter extends Terrain {
  static SYMBOLS = [
    Symbol.of("∞", RED, null, RED, BUILDING_FLOOR),
    Symbol.of("∞", SALMON, null, SALMON, BUILDING_FLOOR),
    Symbol.of("∞", ORANGE, null, ORANGE, BUILDING_FLOOR),
    Symbol.of("∞", WHITE, null, WHITE, BUILDING_FLOOR),
  ];
  constructor(boardID, x, y) {
    super("Teleporter", TRAVERSABLE | PENETRABLE, Teleporter.SYMBOLS[0]);
    this._boardID = boardID;
    this._x = x;
    this._y = y;
  }
  onEnter(event, player, _cell, dir) {
    player.teleport(event, dir, this._boardID, this._x, this._y);
  }
  onFrame(event, cell, frame) {
    this.symbol = Teleporter.SYMBOLS[frame % 4];
    cell.board._notifyCellChange(cell);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([boardID, x, y]) {
      return new Teleporter(boardID, parseInt(x) || 0, parseInt(y) || 0);
    }
    store(t) {
      return `Teleporter|${t._boardID}|${t._x}|${t._y}`;
    }
    example() {
      return new Teleporter("board1", 0, 0);
    }
    template(_id) {
      return "Teleporter|{boardID}|{x}|{y}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * ForceField — when ON, strips all carried items on entry.
 * When OFF, behaves like the wrapped terrain.
 */
export class ForceField extends Terrain {
  static COLORS = [RED, YELLOW, ORANGE, VIOLET];
  constructor(terrain, direction, color, state) {
    const bg = terrain.symbol.getBackground(false);
    const bgOut = terrain.symbol.getBackground(true);
    const sym = state.isOff()
      ? (terrain.symbol ?? Symbol.of("\u2003", null))
      : direction === NORTH
        ? Symbol.of("|", RED, bg, RED, bgOut)
        : Symbol.of("—", RED, bg, RED, bgOut);
    super("Force Field", TRAVERSABLE | PENETRABLE, color, sym);
    this._direction = direction;
    this._state = state;
    this._terrain = terrain;
  }
  onColorEvent(_event, color, cell) {
    if (color !== this.color) {
      return;
    }
    TerrainUtils.toggleCellState(cell, this, this._state);
  }
  randomSeed() {
    return true;
  }
  onFrame(_event, cell, frame) {
    if (!this._state.isOn()) {
      return;
    }
    const outside = cell.board.outside;
    const bg = this._terrain.symbol.getBackground(outside);
    const char = this._direction === NORTH ? "|" : "\u2014";
    this.symbol = Symbol.of(char, ForceField.COLORS[frame % 4], bg);
    cell.board._notifyCellChange(cell);
  }
  onEnter(_event, player, cell, dir) {
    if (!this._state.isOn()) {
      return;
    }
    const bag = player.bag;
    if (!bag) {
      return;
    }
    const entries = [...bag.entries].filter(
      (e) => e.piece.name !== EMPTY_HANDED,
    );
    if (entries.length === 0) {
      return;
    }
    const prevCell = cell.getAdjacentCell?.(dir?.reverse);
    for (const entry of entries) {
      const count = entry.count;
      for (let j = 0; j < count; j++) {
        prevCell?.addItem?.(entry.piece);
        bag.remove(entry.piece);
      }
    }
    events.fireModalMessage(
      "All your stuff gets flung from your body. It's kind of embarrassing.",
    );
  }
  onFlyOver(event, _cell, _flier) {
    if (this._state.isOn()) {
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      if (args.length >= 4) {
        return new ForceField(
          _rt(args[0]),
          directionByName(args[1]) ?? DIR_NONE,
          colorByName(args[2]) ?? NONE,
          stateFromString(args[3]) ?? ON,
        );
      }
      return new ForceField(
        Registry.get("Floor"),
        directionByName(args[0]) ?? DIR_NONE,
        colorByName(args[1]) ?? NONE,
        stateFromString(args[2]) ?? ON,
      );
    }
    store(ff) {
      return `ForceField|${this.esc(ff._terrain)}|${ff._direction?.name ?? "none"}|${ff.color.name}|${ff._state.name}`;
    }
    example() {
      return new ForceField(new Floor(), DIR_NONE, NONE, OFF);
    }
    template(_id) {
      return "ForceField|{terrain?}|{direction}|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Reflector — ETHEREAL|AMMO_ENLIVENER, deflects in-flight items.
 * Direction: north=|, northeast=/, east=—, southeast=\
 */
export class Reflector extends Terrain {
  constructor(direction, color) {
    const entity =
      direction === NORTH
        ? "|"
        : direction === NORTHEAST
          ? "/"
          : direction === EAST
            ? "\u2014"
            : "\\";
    super(
      (color && color !== NONE ? color.name + " " : "") + "Reflector",
      ETHEREAL | AMMO_ENLIVENER,
      color,
      Symbol.of(entity, color, SILVER),
    );
    this._reflectorDir = direction;
  }
  onEnter(event, _player, cell, _dir) {
    event.cancel();
    _reflectorRotate(cell, this);
  }
  onColorEvent(_event, color, cell) {
    if (color === this.color) {
      _reflectorRotate(cell, this);
    }
  }
  onFlyOver(_event, _cell, flier) {
    if (flier.direction) {
      const reflected = _reflectedDir(this._reflectorDir, flier.direction);
      if (reflected) {
        flier.direction = reflected;
      }
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir, color]) {
      return new Reflector(
        directionByName(dir) ?? EAST,
        colorByName(color) ?? NONE,
      );
    }
    store(r) {
      return `Reflector|${r._reflectorDir?.name ?? r._reflectorDir}|${r.color.name}`;
    }
    example() {
      return new Reflector(NORTH, RED);
    }
    template(_id) {
      return "Reflector|{direction}|{color}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

function _reflectorRotate(cell, reflector) {
  const dirNames = ["north", "northeast", "east", "southeast"];
  const curName = reflector._reflectorDir?.name ?? "east";
  const idx = dirNames.indexOf(curName);
  const nextName = dirNames[(idx + 1) % dirNames.length];
  try {
    const key = Registry.serialize(reflector);
    const parts = key.split("|");
    // key = Reflector|{dir}|{color}
    const colorPart = parts[2] ?? parts[1];
    cell.setTerrain(Registry.get(`Reflector|${nextName}|${colorPart}`));
  } catch (_) {
    /* ignore if rotation fails */
  }
}

const map = {
  north: {
    southeast: "southwest",
    southwest: "southeast",
    northeast: "northwest",
    northwest: "northeast",
  },
  northeast: { south: "west", west: "south", north: "east", east: "north" },
  east: {
    southeast: "northeast",
    northeast: "southeast",
    southwest: "northwest",
    northwest: "southwest",
  },
  southeast: { east: "south", south: "east", north: "west", west: "north" },
};

function _reflectedDir(mirror, incoming) {
  const mirrorName = mirror.name ?? String(mirror);
  const incomingName = incoming.name ?? String(incoming);
  const row = map[mirrorName];

  if (!row || !row[incomingName]) {
    return incoming.reverse ?? incoming;
  }
  return directionByName(row[incomingName]) ?? incoming;
}

/**
 * BeeHive — impassable, spawns KillerBees on color event.
 */
export class BeeHive extends Terrain {
  constructor(color, state) {
    super("Bee Hive", 0, color, Symbol.of("⌂", DARKGOLDENROD, GOLD));
    this._hiveState = state;
    this._hiveColor = color;
  }
  canExit(agent, _cell, _dir) {
    return agent?.name === "Killer Bee";
  }
  onEnter(event, _player, cell, _dir) {
    event.cancel();
    _annoyHive(cell, this._hiveColor, this._hiveState);
  }
  onFlyOver(event, cell, _flier) {
    event.cancel();
    _annoyHive(cell, this._hiveColor, this._hiveState);
  }
  onColorEvent(_event, color, cell) {
    if (color !== this._hiveColor) return;
    if (cell.agent == null) {
      try {
        cell.setAgent(Registry.get(`KillerBee|${this._hiveColor.name}`));
      } catch (_) {
        /* bee not available */
      }
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, state]) {
      return new BeeHive(
        colorByName(color) ?? NONE,
        stateFromString(state) ?? OFF,
      );
    }
    store(bh) {
      return `BeeHive|${bh._hiveColor.name}|${bh._hiveState.name}`;
    }
    example() {
      return new BeeHive(NONE, OFF);
    }
    template(_id) {
      return "BeeHive|{color}|{state}";
    }
    tag() {
      return "Agents";
    }
  })();
}

function _annoyHive(cell, color, state) {
  if (state.isOn()) {
    return;
  }
  try {
    const bee = Registry.get(`KillerBee|${color.name}`);
    const adj = cell.getAdjacentCells() ?? [];
    for (const c of adj) {
      if (c.agent == null) {
        c.setAgent(bee);
        break;
      }
    }
    TerrainUtils.toggleCellState(cell, cell.terrain, state);
  } catch (_) {
    /* ignore */
  }
}

/**
 * FarthapodNest — impassable, spawns Farthapods on color event.
 */
export class FarthapodNest extends Terrain {
  constructor(color) {
    super(
      "Farthapod Nest",
      0,
      color,
      Symbol.of("≡", LIGHTSTEELBLUE, BLACK, DARKSLATEBLUE, GRASS),
    );
    this._nestColor = color;
  }
  canExit(agent, _cell, _dir) {
    return agent?.name === "Farthapod";
  }
  onColorEvent(_event, color, cell) {
    if (color !== this._nestColor) return;
    if (cell.agent == null) {
      try {
        cell.setAgent(Registry.get(`Farthapod|${this._nestColor.name}`));
      } catch (_) {
        /* ignore */
      }
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color]) {
      return new FarthapodNest(colorByName(color) ?? NONE);
    }
    store(fn) {
      return `FarthapodNest|${fn._nestColor.name}`;
    }
    example() {
      return new FarthapodNest(NONE);
    }
    template(_id) {
      return "FarthapodNest|{color}";
    }
    tag() {
      return "Agents";
    }
  })();
}

/**
 * Turnstile — one-way passage (east or west only).
 * A color event reverses the direction.
 */
export class Turnstile extends Terrain {
  constructor(direction, color) {
    const entity = direction === WEST ? "«" : "»";
    super(
      "Turnstile",
      0,
      color,
      Symbol.of(entity, WHITE, BLACK, BLACK, BUILDING_FLOOR),
    );
    this._turnDir = direction;
  }
  canEnter(_agent, _cell, dir) {
    return dir === this._turnDir || dir.name === this._turnDir.name;
  }
  canExit(_agent, _cell, dir) {
    return dir === this._turnDir || dir.name === this._turnDir.name;
  }
  onEnter(event, _player, _cell, dir) {
    if (!this.canEnter(null, null, dir)) {
      event.cancel();
    }
  }
  onExit(event, _player, _cell, dir) {
    if (!this.canExit(null, null, dir)) {
      event.cancel();
    }
  }
  onAgentEnter(event, _agent, _cell, dir) {
    if (!this.canEnter(null, null, dir)) {
      event.cancel();
    }
  }
  onAgentExit(event, _agent, _cell, dir) {
    if (!this.canExit(null, null, dir)) {
      event.cancel();
    }
  }
  onFlyOver(event, _cell, flier) {
    if (!this.canEnter(null, null, flier.direction)) {
      event.cancel();
    }
  }
  onColorEvent(_event, color, cell) {
    if (color === this.color) {
      const newDirName = this._turnDir === EAST ? "west" : "east";
      cell.setTerrain(
        Registry.get(`Turnstile|${newDirName}|${this.color.name}`),
      );
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([dir, color]) {
      return new Turnstile(directionByName(dir), colorByName(color) ?? NONE);
    }
    store(t) {
      return `Turnstile|${t._turnDir?.name ?? t._turnDir}|${t.color.name}`;
    }
    example() {
      return new Turnstile(EAST, NONE);
    }
    template(_id) {
      return "Turnstile|{direction}|{color}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Shooter — fires an ammo item in a direction (animated via board's turn cycle).
 */
export class Shooter extends Terrain {
  constructor(ammo, direction, color, state) {
    super(
      "Shooter",
      0,
      color,
      Symbol.of("ж", SALMON, NEARBLACK, SALMON, BUILDING_WALL),
    );
    this._shootAmmo = ammo;
    this._shootDir = direction;
    this._shootState = state;
  }
  onColorEvent(_event, color, cell) {
    if (color !== this.color) {
      return;
    }
    TerrainUtils.toggleCellState(cell, this, this._shootState);
  }
  onFlyOver(event, _cell, flier) {
    // Allow only the shooter's own ammo to leave
    if (flier.item !== this._shootAmmo) {
      event.cancel();
    }
  }
  onFrame(event, cell, frame) {
    if (frame % 5 !== 0 || !this._shootState.isOn()) {
      return;
    }
    const dir =
      this._shootDir === DIR_NONE ? getRandomDirection() : this._shootDir;
    game.shoot(event, cell, this, this._shootAmmo, dir);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      const ammo = _rt(args[0]);
      if (args.length >= 4) {
        return new Shooter(
          ammo,
          directionByName(args[1]) ?? DIR_NONE,
          colorByName(args[2]) ?? NONE,
          stateFromString(args[3]) ?? ON,
        );
      }
      return new Shooter(
        ammo,
        DIR_NONE,
        colorByName(args[1]) ?? NONE,
        stateFromString(args[2]) ?? ON,
      );
    }
    store(s) {
      return `Shooter|${this.esc(s._shootAmmo)}|${s._shootDir?.name ?? "none"}|${s.color.name}|${s._shootState.name}`;
    }
    example() {
      return null;
    }
    template(_id) {
      return "Shooter|{ammo}|{direction}|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * FishPool — a fishing spot on top of existing terrain.
 * With a FishingPole selected, player catches a Fish.
 */
export class FishPool extends Terrain {
  constructor(terrain, state) {
    const bg = terrain.symbol.getBackground(false);
    const bgOut = terrain.symbol.getBackground(true);
    super(
      state.isOn()
        ? (terrain.name ?? "Water") + " with Fish"
        : (terrain.name ?? "Water"),
      AQUATIC,
      SURF,
      state.isOn()
        ? Symbol.of("α", SURF, bg, SURF, bgOut)
        : Symbol.of("\u2003", SURF, bg, SURF, bgOut),
    );
    this._poolTerrain = terrain;
    this._poolState = state;
  }
  onEnter(event, player, cell, _dir) {
    const sel = player.bag.getSelected();
    if (this._poolState.isOn() && sel instanceof FishingPole) {
      player.bag.add(Registry.get("Fish"));
      events.fireMessage(cell, "You caught a fish!");
      TerrainUtils.toggleCellState(cell, this, this._poolState);
      event.cancel();
    }
  }
  onFrame(event, cell, frame) {
    if (frame % 8 !== 0) {
      return;
    }
    const isOn = this._poolState.isOn();
    const chanceToChange = isOn ? 40 : 20;
    if (Math.random() * 100 >= chanceToChange) {
      return;
    }
    if (isOn) {
      this._turnOff(cell);
    } else {
      TerrainUtils.toggleCellState(cell, this, this._poolState);
    }
  }
  _turnOff(cell) {
    // Find adjacent cells that have the same base terrain (not a FishPool)
    const board = cell.board;
    const candidates = [];
    for (const dir of [NORTH, SOUTH, EAST, WEST]) {
      const adj = board.getAdjacentCell(cell.x, cell.y, dir);
      if (adj && adj.terrain === this._poolTerrain) {
        candidates.push(adj);
      }
    }
    const target = candidates.length
      ? candidates[Math.floor(Math.random() * candidates.length)]
      : null;
    // Revert this cell to plain base terrain
    cell.setTerrain(this._poolTerrain);
    if (target) {
      // Place an OFF fish pool on the target — it will later turn ON randomly
      const offPool = TerrainUtils.getTerrainOtherState(this, this._poolState);
      target.setTerrain(offPool);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new FishPool(_rt(args[0]), stateFromString(args[1]) ?? ON);
    }
    store(fp) {
      return `FishPool|${this.esc(fp._poolTerrain)}|${fp._poolState.name}`;
    }
    example() {
      return new FishPool(new Floor(), ON);
    }
    template(_id) {
      return "FishPool|{terrain}|{state}";
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * EuclideanEngine — accepts a matching-color EuclideanShard to power up.
 */
export class EuclideanEngine extends Terrain {
  constructor(color, state) {
    super(
      `Euclidean Engine`,
      0,
      color,
      Symbol.of("◊", color, null, color, BUILDING_FLOOR),
    );
    this._engineState = state;
  }
  onEnter(event, player, cell, _dir) {
    event.cancel();
    if (this._engineState.isOn()) {
      return;
    }
    const sel = player.bag.getSelected();
    if (sel instanceof EuclideanShard && sel.color === this.color) {
      player.bag.remove(sel);
      TerrainUtils.toggleCellState(cell, this, this._engineState);
      _checkAllEnginesOn(event);
    } else if (sel && sel.name !== EMPTY_HANDED) {
      events.fireMessage(cell, `The ${sel.name} doesn't seem to help`);
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([color, state]) {
      return new EuclideanEngine(
        colorByName(color) ?? NONE,
        stateFromString(state) ?? OFF,
      );
    }
    store(ee) {
      return `EuclideanEngine|${ee.color.name}|${ee._engineState.name}`;
    }
    example() {
      return new EuclideanEngine(NONE, OFF);
    }
    template(_id) {
      return "EuclideanEngine|{color}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

function _checkAllEnginesOn(event) {
  let count = 0;
  event.board.visit((cell) => {
    if (
      cell.terrain instanceof EuclideanEngine &&
      cell.terrain._engineState.isOn()
    ) {
      count++;
    }
    return false;
  });
  if (count >= 4) {
    event.board.visit((cell) => {
      if (
        cell.terrain instanceof EuclideanTransporter &&
        !cell.terrain._etState.isOn()
      ) {
        events.fireMessage(
          cell,
          "As the last engine starts, the Euclidean Transporter powers up.",
        );
        TerrainUtils.toggleCellState(cell, cell.terrain, cell.terrain._etState);
      }
      return false;
    });
  }
}

/**
 * EuclideanTransporter — teleports to another board when ON.
 */
export class EuclideanTransporter extends Terrain {
  constructor(boardID, x, y, state) {
    super(
      "Euclidean Transporter",
      TRAVERSABLE | PENETRABLE,
      Symbol.of("Θ", RED, null, RED, BUILDING_FLOOR),
    );
    this._etBoardID = boardID;
    this._etX = x;
    this._etY = y;
    this._etState = state;
  }
  onEnter(event, player, cell, dir) {
    if (this._etState.isOn()) {
      player.teleport(event, dir, this._etBoardID, this._etX, this._etY);
    } else {
      events.fireMessage(cell, "You're standing on a complex device");
    }
  }
  onAgentEnter(event, _agent, _cell, _dir) {
    if (this._etState.isOn()) {
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create(args) {
      return new EuclideanTransporter(
        args[0],
        parseInt(args[1]) || 0,
        parseInt(args[2]) || 0,
        stateFromString(args[3]) ?? OFF,
      );
    }
    store(et) {
      return `EuclideanTransporter|${et._etBoardID}|${et._etX}|${et._etY}|${et._etState.name}`;
    }
    example() {
      return new EuclideanTransporter("board1", 0, 0, OFF);
    }
    template(_id) {
      return "EuclideanTransporter|{boardID}|{x}|{y}|{state}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * Exchanger — swaps the player's selected item with the top item on the adjacent cell.
 */
export class Exchanger extends Terrain {
  constructor() {
    super(
      "Exchanger",
      Symbol.of("÷", LIGHTSLATEGRAY, NEARBLACK, DARKSLATEGRAY, BUILDING_WALL),
    );
  }
  onEnter(event, player, cell, dir) {
    event.cancel();
    if (dir.isDiagonal()) {
      return;
    }
    const otherCell = cell.getAdjacentCell(dir);
    if (!otherCell) {
      return;
    }
    const otherItem = otherCell.topItem ?? null;
    const sel = player.bag.getSelected() ?? player.bag.selected;
    if (sel instanceof Rock) {
      events.fireMessage(cell, "The exchanger does not accept rocks");
    } else if (sel && sel.name !== EMPTY_HANDED && otherItem) {
      otherCell.removeItem(otherItem);
      otherCell.addItem(sel);
      player.bag.exchange(otherItem);
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Exchanger");
    }
    create(_args) {
      return new Exchanger();
    }
    tag() {
      return "Room Features";
    }
  })();
}

/**
 * VendingMachine — sells items from the adjacent cell for GoldCoins.
 * Format: VendingMachine|{cost}
 */
export class VendingMachine extends Terrain {
  constructor(cost) {
    super(
      `Vending Machine (${cost} coins)`,
      Symbol.of("\u00F7", GOLD, NEARBLACK, GOLDENROD, BUILDING_WALL),
    );
    this._cost = cost;
  }
  onEnter(event, player, cell, dir) {
    event.cancel();
    if (dir.isDiagonal()) {
      return;
    }
    const otherCell = cell.getAdjacentCell(dir);
    if (!otherCell) {
      return;
    }
    const otherItem = otherCell.bag.last() ?? null;
    if (!otherItem) {
      events.fireMessage(cell, "The vending machine is empty");
      return;
    }
    const bag = player.bag;
    const goldCount = bag?.count?.("Gold Coin") ?? 0;
    if (goldCount < this._cost) {
      events.fireMessage(cell, `You need ${this._cost} gold coins`);
      return;
    }
    let removed = 0;
    while (removed < this._cost) {
      const coin = bag?.find?.((i) => i.name === "Gold Coin");
      if (!coin) break;
      bag.remove(coin);
      removed++;
    }
    otherCell.bag?.remove?.(otherItem);
    bag.add?.(otherItem);
    events.fireMessage(cell, `You purchase the ${otherItem.name}`);
  }
  static SERIALIZER = new (class extends BaseSerializer {
    create([cost]) {
      return new VendingMachine(parseInt(cost) || 1);
    }
    store(vm) {
      return `VendingMachine|${vm._cost}`;
    }
    example() {
      return new VendingMachine(1);
    }
    template(_id) {
      return "VendingMachine|{cost}";
    }
    tag() {
      return "Room Features";
    }
  })();
}

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

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

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

export function registerBasicTerrain() {
  Registry.register("Crevasse", Crevasse.SERIALIZER);
  Registry.register("Floor", Floor.SERIALIZER);
  Registry.register("Wall", Wall.SERIALIZER);
  Registry.register("Dirt", Dirt.SERIALIZER);
  Registry.register("Field", Field.SERIALIZER);
  Registry.register("Forest", Forest.SERIALIZER);
  Registry.register("Sky", Sky.SERIALIZER);
  Registry.register("Cloud", Cloud.SERIALIZER);
  Registry.register("ChalkedFloor", ChalkedFloor.SERIALIZER);
  Registry.register("TrashPile", TrashPile.SERIALIZER);
  Registry.register("Haystack", Haystack.SERIALIZER);
  Registry.register("Pier", Pier.SERIALIZER);
  Registry.register("WoodPiling", WoodPiling.SERIALIZER);
  Registry.register("Sand", Sand.SERIALIZER);
  Registry.register("LowRocks", LowRocks.SERIALIZER);
  Registry.register("HighRocks", HighRocks.SERIALIZER);
  Registry.register("ImpassableCliffs", ImpassableCliffs.SERIALIZER);
  Registry.register("PyramidWall", PyramidWall.SERIALIZER);
  Registry.register("Fence", Fence.SERIALIZER);
  Registry.register("Boards", Boards.SERIALIZER);
  Registry.register("Bridge", Bridge.SERIALIZER);
  Registry.register("Flowers", Flowers.SERIALIZER);
  Registry.register("Grass", Grass.SERIALIZER);
  Registry.register("TallGrass", TallGrass.SERIALIZER);
  Registry.register("BunchGrass", BunchGrass.SERIALIZER);
  Registry.register("BeachGrass", BeachGrass.SERIALIZER);
  Registry.register("SwampGrass", SwampGrass.SERIALIZER);
  Registry.register("Scrub", Scrub.SERIALIZER);
  Registry.register("Weeds", Weeds.SERIALIZER);
  Registry.register("Bushes", Bushes.SERIALIZER);
  Registry.register("Throne", Throne.SERIALIZER);
  Registry.register("WishingWell", WishingWell.SERIALIZER);
  Registry.register("Teleporter", Teleporter.SERIALIZER);
  Registry.register("ForceField", ForceField.SERIALIZER);
  Registry.register("Reflector", Reflector.SERIALIZER);
  Registry.register("BeeHive", BeeHive.SERIALIZER);
  Registry.register("FarthapodNest", FarthapodNest.SERIALIZER);
  Registry.register("Turnstile", Turnstile.SERIALIZER);
  Registry.register("Shooter", Shooter.SERIALIZER);
  Registry.register("FishPool", FishPool.SERIALIZER);
  Registry.register("EuclideanEngine", EuclideanEngine.SERIALIZER);
  Registry.register("EuclideanTransporter", EuclideanTransporter.SERIALIZER);
  Registry.register("Exchanger", Exchanger.SERIALIZER);
  Registry.register("VendingMachine", VendingMachine.SERIALIZER);
}