core/terrain-utils.ts

import { Decorator } from "../pieces/terrain/decorators.ts";
import { Cell } from "./cell.ts";
import { Direction } from "./direction.ts";
import { GameEvent } from "./game-event.ts";
import { Registry } from "./registry.ts";
import { State } from "./state.ts";
import { Terrain } from "./terrain.ts";
import { COLUMNS, ROWS } from "./board.ts";

/**
 * Utility helpers for terrain manipulation.
 * Mirrors TerrainUtils.java.
 */
export const TerrainUtils = {
  /**
   * Remove a decorator from every cell on the board where it appears,
   * replacing it with the decorator's proxied (wrapped) terrain.
   *
   * Used by TriggerOnce and similar one-shot decorators.
   */
  removeDecorator(event: GameEvent, terrain: Terrain) {
    event.board.visit((cell) => {
      if (cell.terrain === terrain && terrain instanceof Decorator) {
        // terrain is a Decorator — replace it with the proxied terrain
        cell.setTerrain(terrain.getProxiedTerrain());
      }
      return true; // keep visiting
    });
  },

  /**
   * Toggle the state of the terrain in `cell` (ON→OFF or OFF→ON).
   * Does nothing if an agent occupies the cell.
   */
  toggleCellState(cell: Cell, terrain: Terrain, state: State): Terrain | null {
    if (cell.agent == null) {
      const other = this.getTerrainOtherState(terrain, state);
      cell.setTerrain(other);
      return other;
    }
    return null;
  },

  /**
   * Return the same terrain type but with its state toggled (ON↔OFF).
   * Works by manipulating the Registry serialization key: splits on "|" and
   * replaces the segment that exactly equals "on"/"off" with the other,
   * then looks up the new key. Splitting on the pipe delimiter (rather than
   * regexing across the whole string) avoids misfiring on a free-text arg
   * that happens to be exactly that word (a Pylon's board path, an NPC's
   * greeting text).
   */
  getTerrainOtherState(terrain: Terrain, state: State): Terrain {
    const target = state.isOn() ? "on" : "off";
    const replacement = state.isOn() ? "off" : "on";
    const key = Registry.serialize(terrain)
      .split("|")
      .map((segment) => (segment === target ? replacement : segment))
      .join("|");
    return Registry.terrain(key);
  },

  /**
   * Return the cell on the opposite side of the board from `cell`
   * when traveling in `direction`. Used by rolling-boulder wrap-around.
   *
   * A diagonal direction (e.g. NORTHEAST) matches two of the four checks
   * below, so tie-break order matters: at the top/bottom edge, vertical
   * wrap wins; everywhere else, horizontal wrap wins. Existing tests pin
   * this exact order.
   */
  getCellOnOppositeSide(cell: Cell, direction: Direction): Cell | null {
    const wrapNorth = () => cell.board.getCellAt(cell.x, ROWS - 1);
    const wrapSouth = () => cell.board.getCellAt(cell.x, 0);
    const wrapEast = () => cell.board.getCellAt(0, cell.y);
    const wrapWest = () => cell.board.getCellAt(COLUMNS - 1, cell.y);

    const checks: Array<[boolean, () => Cell | null]> =
      cell.y === 0 || cell.y === ROWS - 1
        ? [
          [direction.isNortherly(), wrapNorth],
          [direction.isSoutherly(), wrapSouth],
          [direction.isEasterly(), wrapEast],
          [direction.isWesterly(), wrapWest],
        ]
        : [
          [direction.isEasterly(), wrapEast],
          [direction.isWesterly(), wrapWest],
          [direction.isNortherly(), wrapNorth],
          [direction.isSoutherly(), wrapSouth],
        ];

    for (const [matches, wrap] of checks) {
      if (matches) return wrap();
    }
    return null;
  },
};