core/cell.ts

import { ADJ_DIRECTIONS, EAST, NORTH, NORTHEAST, NORTHWEST, SOUTH, SOUTHEAST, SOUTHWEST, WEST } from "./direction.ts";
import { Agent } from "./agent.ts";
import { AgentProxy } from "./agent-proxy.ts";
import { Board } from "./board.ts";
import { Color } from "./color.ts";
import { Direction } from "./direction.ts";
import { Effect } from "./effect.ts";
import { events } from "./event-bus.ts";
import { GameEvent } from "./game-event.ts";
import { isAnimated } from "./animated.ts";
import { Item } from "./item.ts";
import { Open, Fire } from "../pieces/effects/effects.ts";
import { Player } from "./player.ts";
import { PLAYER, HIDES_ITEMS, MEAT, PENETRABLE } from "./flags.ts";
import { Registry } from "./registry.ts";
import { Sym } from "./sym.ts";
import { Terrain } from "./terrain.ts";

/**
 * A single cell on the board. Holds terrain, one optional agent, a list of
 * items on the ground, and a list of active effects.
 *
 * Cells do not fire DOM or network events — they notify the board, which fans
 * out to any registered change listeners (e.g. the renderer).
 */
export class Cell {
  board: Board;
  x: number;
  y: number;
  terrain!: Terrain;
  agent: Agent | null;
  items: Item[];
  effects: Effect[];
  animTerrainSymbol: Sym | null;
  animItemSymbol: Sym | null;
  animAgentSymbol: Sym | null;
  visited: number;
  #meatCount: number;

  constructor(board: Board, x: number, y: number) {
    this.board = board;
    this.x = x;
    this.y = y;
    this.agent = null;
    this.items = [];
    this.effects = [];
    /**
     * Per-cell animated terrain symbol set by onFrame. Equivalent to Java's
     * fireRerender(cell, piece, symbol) — lets singleton terrain pieces render
     * different symbols at different positions without lock-step.
     */
    this.animTerrainSymbol = null;
    /**
     * Per-cell animated item symbol set by onFrame. Same mechanism as
     * animTerrainSymbol but for the topmost item.
     */
    this.animItemSymbol = null;
    /**
     * Per-cell animated agent symbol set by onFrame. Same mechanism as
     * animTerrainSymbol but for the agent layer, allowing singleton agents
     * to animate independently on each cell.
     */
    this.animAgentSymbol = null;
    /**
     * Breadcrumb counter: updated each time the player steps here.
     * Used for pathfinding heuristics.
     */
    this.visited = 0;
    /** Count of MEAT-flagged items on this cell. Mirrors Java's ItemBag.meatCount. */
    this.#meatCount = 0;
  }

  /** True if the player is currently standing on this cell. */
  containsPlayer() {
    return this.x === this.board.playerX && this.y === this.board.playerY;
  }

  /**
   * The adjacent cell in the given direction, or null for vertical directions
   * (UP/DOWN cross board boundaries, handled at a higher level).
   */
  getAdjacentCell(direction: Direction): Cell | null {
    if (direction.isVertical()) {
      return null;
    }
    return this.board.getCellAt(
      this.x + direction.xDelta,
      this.y + direction.yDelta,
    );
  }

  /**
   * Get the adjacent cells that this agent can enter from this cell.
   */
  public getAdjacentCells(agent: Agent): Cell[] {
    const cells: Cell[] = [];
    const dirs: Direction[] = [NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST];
    for (var i = 0; i < dirs.length; i++) {
      let cell = this.getAdjacentCell(dirs[i]);
      if (cell && this.canEnter(this, agent, dirs[i])) {
        cells.push(cell);
      }
    }
    return cells;
  }

  /**
   * Replace the terrain on this cell. Fires a cell-change notification.
   */
  setTerrain(terrain: Terrain) {
    if (isAnimated(this.terrain)) {
      this.board.removeAnimated(this.x, this.y, this.terrain);
    }
    this.animTerrainSymbol = null;
    this.terrain = terrain;
    if (isAnimated(terrain)) {
      this.board.addAnimated(this.x, this.y, terrain);
    }
    this.board.notifyCellChange(this);
  }

  /**
   * The terrain as it should appear to the player. Subclasses that implement
   * a TerrainProxy decorator should override this to return the wrapped terrain.
   */
  getApparentTerrain() {
    return this.terrain;
  }

  /**
   * Place an agent on this cell. Any previously present agent is cleared first.
   * Do NOT pass null — use removeAgent() to clear.
   */
  setAgent(agent: Agent) {
    if (this.agent != null) {
      this.#clearAgent(this.agent);
    }
    this.agent = agent;
    if (agent.is(PLAYER)) {
      this.board.playerX = this.x;
      this.board.playerY = this.y;
      this.visited = this.board.visitCount++;
    }
    if (this.#shouldAnimate(agent)) {
      this.board.addAnimated(this.x, this.y, agent);
    }
    this.board.notifyCellChange(this);
  }

  /**
   * Remove the given agent from this cell (no-op if agent is null or differs).
   */
  removeAgent(agent: Agent | null) {
    if (agent != null && this.agent === agent) {
      this.#clearAgent(agent);
      this.agent = null;
      this.board.notifyCellChange(this);
    }
  }

  /**
   * Move an agent from this cell to another cell in one operation (more
   * efficient than remove + set, avoids creating an intermediate state).
   */
  moveAgentTo(next: Cell, agent: Agent) {
    if (this.#shouldAnimate(agent)) {
      this.board.moveAnimated(this.x, this.y, next.x, next.y, agent);
    }
    this.agent = null;
    this.animAgentSymbol = null;
    next.agent = agent;
    if (agent.is(PLAYER)) {
      this.board.playerX = next.x;
      this.board.playerY = next.y;
      next.visited = this.board.visitCount++;
    }
    this.board.notifyCellChange(this);
    this.board.notifyCellChange(next);
  }

  #clearAgent(agent: Agent) {
    if (this.#shouldAnimate(agent)) {
      this.board.removeAnimated(this.x, this.y, agent);
    }
    this.animAgentSymbol = null;
  }

  /**
   * Regular players are not in the animated list (their onFrame has a
   * different signature and is called separately). Proxy-wrapped players
   * (e.g. Paralyzed, Statue) ARE registered, so they're included here.
   */
  #shouldAnimate(agent: Agent): boolean {
    return isAnimated(agent) && (!agent.is(PLAYER) || agent instanceof AgentProxy);
  }

  /** True if there are no items on the ground in this cell. */
  get isBagEmpty() {
    return this.items.length === 0;
  }

  /** True if at least one MEAT-flagged item is on the ground here. Mirrors Java's ItemBag.containsMeat(). */
  containsMeat() {
    return this.#meatCount > 0;
  }

  /** The topmost item, or null. */
  get topItem() {
    return this.items.length > 0 ? this.items[this.items.length - 1] : null;
  }

  addItem(item: Item) {
    if (isAnimated(item)) {
      this.board.addAnimated(this.x, this.y, item);
    }
    this.animItemSymbol = null;
    this.items.push(item);
    if (item.is(MEAT)) {
      this.#meatCount++;
    }
    this.board.notifyCellChange(this);
  }

  /**
   * Remove the last occurrence of the given item from this cell.
   */
  removeItem(item: Item) {
    if (isAnimated(item)) {
      this.board.removeAnimated(this.x, this.y, item);
    }
    this.animItemSymbol = null;
    const i = this.items.lastIndexOf(item);
    if (i !== -1) {
      this.items.splice(i, 1);
      if (item.is(MEAT)) {
        this.#meatCount--;
      }
    }
    this.board.notifyCellChange(this);
  }

  /** True if there are no active effects in this cell. */
  get hasNoEffects() {
    return this.effects.length === 0;
  }

  /** The topmost effect, or null. */
  get topEffect() {
    return this.effects.length > 0
      ? this.effects[this.effects.length - 1]
      : null;
  }

  addEffect(effect: Effect) {
    this.board.addAnimated(this.x, this.y, effect);
    this.effects.push(effect);
    this.board.notifyCellChange(this);
  }

  removeEffect(effect: Effect) {
    this.board.removeAnimated(this.x, this.y, effect);
    const i = this.effects.indexOf(effect);
    if (i !== -1) {
      this.effects.splice(i, 1);
    }
    this.board.notifyCellChange(this);
  }

  /**
   * Can an agent enter this cell from agentLoc in direction dir?
   */
  canEnter(agentLoc: Cell, agent: Agent, dir: Direction, targetPlayer = false) {
    if (!agent.canEnter(dir, agentLoc, this)) {
      return false;
    }
    if (this.agent !== null && !(this.agent.is(PLAYER) && targetPlayer)) {
      return false;
    }
    return this.terrain instanceof Terrain
      ? this.terrain.canEnter(agent, this, dir)
      : false;
  }

  /**
   * Notify items on this cell that an agent has stepped onto it.
   */
  onSteppedOn(event: GameEvent, agentLoc: Cell, agent: Agent) {
    // Iterate a copy in case an item removes itself during iteration
    for (const item of [...this.items]) {
      item.onSteppedOn(event, agentLoc, agent);
    }
  }

  /**
   * Trigger a fire explosion at this cell, spreading to all adjacent cells
   * whose terrain can be entered. Mirrors Java Cell.explosion().
   */
  explosion(player: Player) {
    for (const dir of ADJ_DIRECTIONS) {
      const adj = this.board.getAdjacentCell(this.x, this.y, dir);
      if (adj && adj.terrain instanceof Terrain) {
        if (adj.terrain.canEnter(player, adj, dir)) {
          adj.addEffect(new Fire());
        }
      }
    }
    this.addEffect(new Fire());
  }

  /**
   * Spread an effect cloud from this cell to nearby penetrable cells within
   * a radius of 2.5. Mirrors Java Cell.createCloud().
   */
  createCloud(effectKey: string) {
    const cloud = Registry.effect(effectKey);
    const origin = this;
    this.board.visitRange(this, 4, true, (cell) => {
      const dx = cell.x - origin.x;
      const dy = cell.y - origin.y;
      if (cell.terrain.is(PENETRABLE) && Math.sqrt(dx * dx + dy * dy) <= 2.5) {
        cell.addEffect(cloud);
      }
      return true;
    });
  }

  /**
   * Open a container (Chest, Crate, etc.): change the terrain to the empty
   * state, add items to the player's bag, and fire a message.
   */
  openContainer(containerName: string, item: Item | null, count: number, emptyTerrainKey: string, player: Player) {
    // Swap terrain immediately so the container can't be triggered again
    // while the animation plays (mirrors Java Cell.openContainer).
    this.setTerrain(Registry.terrain(emptyTerrainKey));

    // Play the Open animation, then drop items onto the cell floor on completion.
    const openEffect = new Open(() => {
      if (item) {
        for (let i = 0; i < count; i++) {
          this.addItem(item);
        }
      } else {
        events.fireMessage(`The ${containerName} is empty`, this);
      }
    });
    this.addEffect(openEffect);
  }

  /**
   * Compute the display symbol for this cell by layering terrain → item →
   * under-effects → agent → above-effects. Used by the board renderer.
   *
   * The layering order mirrors the original Java getCurrentSymbol() logic:
   *   1. terrain (or the piece being queried if it IS the terrain)
   *   2. top item (unless terrain has HIDES_ITEMS)
   *   3. under-effects (isAboveAgent === false)
   *   4. agent
   *   5. above-effects (isAboveAgent === true)
   */
  getDisplaySymbol(outside: boolean): { entity: string, fg: Color | null, bg: Color | null } {
    let entity = " ";
    let fg: Color | null = null;
    let bg: Color | null = null;

    const layer = (sym: Sym | null | undefined) => {
      if (!sym) {
        return;
      }
      if (sym.entity && sym.entity !== " ") {
        entity = sym.entity;
      }
      fg = sym.getColor(outside) ?? fg;
      bg = sym.getBackground(outside) ?? bg;
    };

    const t = this.getApparentTerrain();
    layer(this.animTerrainSymbol ?? t?.symbol);

    if (t && !t.is(HIDES_ITEMS)) {
      layer(this.animItemSymbol ?? (this.topItem?.symbol as Sym));
    }

    const topFx = this.topEffect;
    if (topFx && !topFx.isAboveAgent()) {
      layer(topFx.currentSymbol);
    }

    layer(this.animAgentSymbol ?? (this.agent?.symbol as Sym));

    if (topFx && topFx.isAboveAgent()) {
      layer(topFx.currentSymbol);
    }

    return { entity, fg, bg };
  }
}