core/board.js

import { Cell } from "./cell.js";
import { TRANSIENT, TRAVERSABLE } from "./flags.js";
import { AnimationProxy } from "./animation-proxy.js";
import { NONE } from "./color.js";

export const COLUMNS = 40;
export const ROWS = 25;

/**
 * The main model object in the game.
 *
 * Holds a 40×25 grid of Cell objects (indexed cells[x][y], x-major), tracks
 * the player's position, adjacent board references (for board-to-board
 * navigation), and animation state.
 *
 * Board is a plain data model — rendering is handled by the board-view module.
 * Change notifications are delivered by calling registered listeners whenever
 * a cell's state is mutated.
 */
export class Board {
  constructor() {
    this.cells = Array.from({ length: COLUMNS }, (_, x) =>
      Array.from({ length: ROWS }, (_, y) => new Cell(this, x, y)),
    );

    /** direction name → board path stem (e.g. "north" → "desert.rimmos") */
    this._adjacentBoards = new Map();

    /** True if the board is set outdoors (affects symbol color rendering). */
    this.outside = false;

    /** Current player column (-1 if not placed). */
    this.playerX = -1;
    /** Current player row (-1 if not placed). */
    this.playerY = -1;

    /** Default player entry column when entering without a transition. */
    this.startX = -1;
    /** Default player entry row when entering without a transition. */
    this.startY = -1;

    /**
     * Monotonically increasing counter stamped onto each cell the player
     * visits. Used for breadcrumb-style pathfinding.
     */
    this.visitCount = 0;

    this.scenarioName = null;
    this.creator = null;
    this.description = null;
    this.startInv = null;
    /**
     * Scenario folder name (e.g. "malloc-wizard"). Used by the editor to
     * resolve adjacent board paths without needing the full server path.
     * @type {string|null}
     */
    this.folder = null;
    /** Path stem identifying this board, e.g. "tutorial/start". */
    this.boardID = "";

    /**
     * List of AnimationProxy objects — one per animated piece currently on the
     * board. Maintained by Cell as agents/terrain are placed and removed.
     * @type {AnimationProxy[]}
     */
    this._animated = [];

    /** @type {Array<function(Cell): void>} */
    this._changeListeners = [];
  }

  /**
   * Register a callback invoked whenever a cell's state changes.
   * @param {function(Cell): void} fn
   */
  onCellChange(fn) {
    this._changeListeners.push(fn);
  }

  /** @param {Cell} cell */
  _notifyCellChange(cell) {
    for (const fn of this._changeListeners) fn(cell);
  }

  /**
   * Register an animated piece at (x, y). Called by Cell when an agent or
   * terrain with an onFrame method is placed.
   * @param {number} x
   * @param {number} y
   * @param {object} piece
   */
  addAnimated(x, y, piece) {
    this._animated.push(
      new AnimationProxy(x, y, piece, piece.randomSeed?.() ?? true),
    );
  }

  /**
   * Deregister the proxy for piece at (x, y). Called by Cell when an agent
   * or animated terrain is removed.
   * @param {number} x
   * @param {number} y
   * @param {object} piece
   */
  removeAnimated(x, y, piece) {
    const i = this._animated.findIndex((p) => p.proxyFor(x, y, piece));
    if (i !== -1) this._animated.splice(i, 1);
  }

  /**
   * Update the (x, y) of the proxy for piece when it moves to an adjacent
   * cell without being removed and re-added. Called by Cell.moveAgentTo.
   * @param {number} fromX
   * @param {number} fromY
   * @param {number} toX
   * @param {number} toY
   * @param {object} piece
   */
  moveAnimated(fromX, fromY, toX, toY, piece) {
    const proxy = this._animated.find((p) => p.proxyFor(fromX, fromY, piece));
    if (proxy) proxy.setXY(toX, toY);
  }

  /**
   * Return the cell at (x, y), or null if out-of-bounds.
   * @param {number} x
   * @param {number} y
   * @returns {Cell|null}
   */
  getCellAt(x, y) {
    if (x < 0 || x >= COLUMNS || y < 0 || y >= ROWS) return null;
    return this.cells[x][y];
  }

  /**
   * Return the cell adjacent to (x, y) in dir, or null.
   * @param {number} x
   * @param {number} y
   * @param {Direction} dir
   * @returns {Cell|null}
   */
  getAdjacentCell(x, y, dir) {
    return this.getCellAt(x + dir.xDelta, y + dir.yDelta);
  }

  /** Return the cell currently occupied by the player. */
  getCurrentCell() {
    return this.getCellAt(this.playerX, this.playerY);
  }

  /**
   * @param {string} directionName  e.g. "north"
   * @param {string} boardPath      path stem, e.g. "desert.rimmos"
   */
  setAdjacentBoard(directionName, boardPath) {
    this._adjacentBoards.set(directionName, boardPath);
  }

  /**
   * @param {Direction} direction
   * @returns {string|null}
   */
  getAdjacentBoard(direction) {
    return this._adjacentBoards.get(direction.name) ?? null;
  }

  /**
   * Call visitor(cell) for every cell on the board.
   * @param {function(Cell, number): void} visitor
   */
  visit(visitor) {
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        visitor(this.cells[x][y]);
      }
    }
  }

  /**
   * Visit cells in expanding rings around center, closest first.
   * visitor(cell, dist) should return false to stop early.
   * @param {Cell} center
   * @param {number} range
   * @param {boolean} includeCenter
   * @param {function(Cell, number): boolean} visitor
   */
  visitRange(center, range, includeCenter, visitor) {
    const sx = center.x;
    const sy = center.y;
    if (includeCenter && !visitor(center, 0)) {
      return;
    }
    for (let dist = 1; dist < range; dist++) {
      for (let dy = -dist; dy <= dist; dy++) {
        if (dy === -dist || dy === dist) {
          for (let dx = -dist; dx <= dist; dx++) {
            const cell = this.getCellAt(sx + dx, sy + dy);
            if (cell && !visitor(cell, dist)) {
              return;
            }
          }
        } else {
          let cell = this.getCellAt(sx + dist, sy + dy);
          if (cell && !visitor(cell, dist)) {
            return;
          }
          cell = this.getCellAt(sx - dist, sy + dy);
          if (cell && !visitor(cell, dist)) {
            return;
          }
        }
      }
    }
  }

  /**
   * Find the first cell matching filter(cell, null), or null.
   * @param {function(Cell, null): boolean} filter
   * @returns {Cell|null}
   */
  find(filter) {
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        if (filter(this.cells[x][y], null)) {
          return this.cells[x][y];
        }
      }
    }
    return null;
  }

  /**
   * Find a random traversable cell with no agent present.
   * @returns {Cell}
   */
  findRandomCell() {
    let found = null;
    do {
      const x = Math.floor(Math.random() * COLUMNS);
      const y = Math.floor(Math.random() * ROWS);
      found = this.cells[x][y];
    } while (
      found.terrain == null ||
      found.terrain.not(TRAVERSABLE) ||
      found.agent !== null
    );
    return found;
  }

  // ── Effect state queries ──────────────────────────────────────────────────

  /**
   * True if any cell has a non-transient effect (used to delay saving).
   * @returns {boolean}
   */
  hasNonTransientEffect() {
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        const cell = this.cells[x][y];
        if (!cell.hasNoEffects) {
          for (const effect of cell.effects) {
            if (effect.not(TRANSIENT)) return true;
          }
        }
      }
    }
    return false;
  }

  // ── Color events ──────────────────────────────────────────────────────────

  /**
   * Broadcast a color event to all terrain and agents on the board.
   * Each piece's onColorEvent() is called; pieces compare their own color
   * to decide whether to react.
   * @param {GameEvent} event
   * @param {string} color   CSS color hex string
   * @param {Cell} _origin   cell that originated the event (informational)
   */
  fireColorEvent(event, color, _origin) {
    if (color === NONE) {
      return;
    }
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        const cell = this.cells[x][y];
        if (cell.terrain?.onColorEvent) {
          cell.terrain.onColorEvent(event, color, cell);
        }
        if (cell.agent?.onColorEvent) {
          cell.agent.onColorEvent(event, color, cell);
        }
      }
    }
  }
}