core/cell.js

import { PLAYER, HIDES_ITEMS } from "./flags.js";
import { ADJ_DIRECTIONS } from "./direction.js";
import { Registry } from "./registry.js";
import { events } from "./events.js";
import { Open, Fire } from "../pieces/effects/effects.js";

/**
 * 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 {
  /**
   * @param {Board} board
   * @param {number} x  column (0-based)
   * @param {number} y  row (0-based)
   */
  constructor(board, x, y) {
    this.board = board;
    this.x = x;
    this.y = y;
    /** @type {Terrain|null} */
    this.terrain = null;
    /** @type {Agent|null} */
    this.agent = null;
    /** @type {Item[]} */
    this.items = [];
    /** @type {Effect[]} */
    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.
     * @type {Symbol|null}
     */
    this._animTerrainSymbol = null;
    /**
     * Per-cell animated item symbol set by onFrame. Same mechanism as
     * _animTerrainSymbol but for the topmost item.
     * @type {Symbol|null}
     */
    this._animItemSymbol = null;
    /**
     * Breadcrumb counter: updated each time the player steps here.
     * Used for pathfinding heuristics.
     */
    this.visited = 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).
   * @param {Direction} direction
   * @returns {Cell|null}
   */
  getAdjacentCell(direction) {
    if (direction == null || direction.isVertical()) {
      return null;
    }
    return this.board.getCellAt(
      this.x + direction.xDelta,
      this.y + direction.yDelta,
    );
  }

  // ── Terrain ──────────────────────────────────────────────────────────────

  /**
   * Replace the terrain on this cell. Fires a cell-change notification.
   * @param {Terrain} terrain
   */
  setTerrain(terrain) {
    if (this.terrain?.onFrame) {
      this.board.removeAnimated(this.x, this.y, this.terrain);
    }
    this._animTerrainSymbol = null;
    this.terrain = terrain;
    if (terrain?.onFrame) {
      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.
   * @returns {Terrain}
   */
  getApparentTerrain() {
    return this.terrain;
  }

  // ── Agent ─────────────────────────────────────────────────────────────────

  /**
   * Place an agent on this cell. Any previously present agent is cleared first.
   * Do NOT pass null — use removeAgent() to clear.
   * @param {Agent} agent
   */
  setAgent(agent) {
    if (agent == null) {
      throw new Error("Use removeAgent() to clear an 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 is a proxy-wrapped player (e.g. Paralyzed), register its
      // onFrame countdown in the animation system.
      if (agent.isAgentProxy && agent.onFrame) {
        this.board.addAnimated(this.x, this.y, agent);
      }
    } else if (agent.onFrame) {
      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).
   * @param {Agent} agent
   */
  removeAgent(agent) {
    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).
   * @param {Cell} next
   * @param {Agent} agent
   */
  moveAgentTo(next, agent) {
    if (agent != null) {
      if (agent.onFrame && !agent.is(PLAYER)) {
        this.board.moveAnimated(this.x, this.y, next.x, next.y, agent);
      }
      this.agent = 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);
  }

  /** @private */
  _clearAgent(agent) {
    // Deregister from animation if it was ever registered. Regular players are
    // not in the animated list (their onFrame has a different signature and is
    // called separately). Proxy-wrapped players (isAgentProxy) ARE registered,
    // so include them here.
    if (agent.onFrame && (!agent.is(PLAYER) || agent.isAgentProxy)) {
      this.board.removeAnimated(this.x, this.y, agent);
    }
  }

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

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

  /** @param {Item} item */
  addItem(item) {
    if (item.onFrame) {
      this.board.addAnimated(this.x, this.y, item);
    }
    this._animItemSymbol = null;
    this.items.push(item);
    this.board._notifyCellChange(this);
  }

  /**
   * Remove the last occurrence of the given item from this cell.
   * @param {Item} item
   */
  removeItem(item) {
    if (item.onFrame) 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);
    }
    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;
  }

  /** @param {Effect} effect */
  addEffect(effect) {
    this.effects.push(effect);
    this.board._notifyCellChange(this);
  }

  /** @param {Effect} effect */
  removeEffect(effect) {
    const i = this.effects.indexOf(effect);
    if (i !== -1) {
      this.effects.splice(i, 1);
    }
    this.board._notifyCellChange(this);
  }

  // ── Movement / game logic ─────────────────────────────────────────────────

  /**
   * Can an agent enter this cell from agentLoc in direction dir?
   * @param {Cell} agentLoc         current cell of the moving agent
   * @param {Agent} agent
   * @param {Direction} dir
   * @param {boolean} [targetPlayer=false]  true if the agent intends to attack the player
   * @returns {boolean}
   */
  canEnter(agentLoc, agent, dir, targetPlayer = false) {
    if (!agent.canEnter(dir, agentLoc, this)) return false;
    if (this.agent !== null && !(this.agent.is(PLAYER) && targetPlayer))
      return false;
    if (this.terrain && !this.terrain.canEnter(agent, this, dir)) return false;
    if (!this.terrain) return false;
    return true;
  }

  /**
   * Notify items on this cell that an agent has stepped onto it.
   * @param {GameEvent} event
   * @param {Cell} agentLoc
   * @param {Agent} agent
   */
  onSteppedOn(event, agentLoc, 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().
   * @param {Player} player
   */
  explosion(player) {
    for (const dir of ADJ_DIRECTIONS) {
      const adj = this.board.getAdjacentCell(this.x, this.y, dir);
      if (adj && adj.terrain?.canEnter(player, adj, dir)) {
        adj.addEffect(new Fire());
      }
    }
    this.addEffect(new Fire());
  }

  // ── Container helpers ─────────────────────────────────────────────────────

  /**
   * Open a container (Chest, Crate, etc.): change the terrain to the empty
   * state, add items to the player's bag, and fire a message.
   *
   * @param {string} containerName  e.g. "chest" or "crate"
   * @param {Item|null} item  item inside (null if empty)
   * @param {number} count          how many to give
   * @param {string} emptyTerrainKey  Registry key for the empty terrain
   * @param {Player} player
   */
  openContainer(containerName, item, count, emptyTerrainKey, player) {
    // Swap terrain immediately so the container can't be triggered again
    // while the animation plays (mirrors Java Cell.openContainer).
    this.setTerrain(Registry.get(emptyTerrainKey));

    // Play the Open animation, then drop items onto the cell floor on completion.
    const cell = this;
    const openEffect = new Open(() => {
      if (item) {
        for (let i = 0; i < count; i++) {
          cell.addItem(item);
        }
      } else {
        events.fireMessage(cell, `The ${containerName} is empty`);
      }
    });
    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)
   *
   * @param {boolean} outside  whether the board is in outside (daylight) mode
   * @returns {{ entity: string, fg: string|null, bg: string|null }}
   */
  getDisplaySymbol(outside) {
    const t = this.getApparentTerrain();
    const tSym = this._animTerrainSymbol ?? t?.symbol;

    let entity = tSym?.entity ?? " ";
    let fg = tSym?.getColor(outside) ?? null;
    let bg = tSym?.getBackground(outside) ?? null;

    const layer = (sym) => {
      if (!sym) return;
      if (sym.entity && sym.entity !== " ") entity = sym.entity;
      const c = sym.getColor(outside);
      if (c) fg = c;
      const b = sym.getBackground(outside);
      if (b) bg = b;
    };

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

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

    layer(this.agent?.symbol);

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

    return { entity, fg, bg };
  }
}