core/board.ts

import { AnimationProxy } from "./animation-proxy.ts";
import { Cell } from "./cell.ts";
import { Color, NONE } from "./color.ts";
import { Direction } from "./direction.ts";
import { GameEvent } from "./game-event.ts";
import { isAnimated } from "./animated.ts";
import { Piece } from "./piece.ts";
import { TRANSIENT, TRAVERSABLE } from "./flags.ts";

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 {
  boardID: string;
  cells: Cell[][];
  creator: string | null;
  description: string | null;
  folder: string | null;
  outside: boolean;
  playerX: number;
  playerY: number;
  scenarioName: string | null;
  startInv: string | null;
  startX: number;
  startY: number;
  visitCount: number;
  adjacentBoards: Map<string, string>;
  animated: AnimationProxy[];
  #changeListeners: Array<(c: Cell) => void>;
  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.
     */
    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 = [];

    this.#changeListeners = [];
  }

  /**
   * Register a callback invoked whenever a cell's state changes.
   */
  onCellChange(fn: (c: Cell) => void) {
    this.#changeListeners.push(fn);
  }

  notifyCellChange(cell: 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.
   */
  addAnimated(x: number, y: number, piece: Piece) {
    this.animated.push(
      new AnimationProxy(x, y, piece, isAnimated(piece) ? piece.randomSeed() : true),
    );
  }

  /**
   * Deregister the proxy for piece at (x, y). Called by Cell when an agent
   * or animated terrain is removed.
   */
  removeAnimated(x: number, y: number, piece: 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.
   */
  moveAnimated(fromX: number, fromY: number, toX: number, toY: number, piece: 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.
   */
  getCellAt(x: number, y: number): Cell | null {
    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.
   */
  getAdjacentCell(x: number, y: number, dir: Direction): Cell | null {
    return this.getCellAt(x + dir.xDelta, y + dir.yDelta);
  }

  /** Return the cell currently occupied by the player. */
  getCurrentCell(): Cell {
    const cell = this.getCellAt(this.playerX, this.playerY);
    if (!cell) {
      throw new Error(`Player not located on board: ${this.playerX}/${this.playerY}`);
    }
    return cell;
  }

  setAdjacentBoard(directionName: string, boardPath: string) {
    this.adjacentBoards.set(directionName, boardPath);
  }

  getAdjacentBoard(direction: Direction): string | null {
    return this.adjacentBoards.get(direction.name) ?? null;
  }

  /**
   * Call visitor(cell) for every cell on the board. Function should
   * return false to short-circuit the visitor
   */
  visit(visitor: (c: Cell, n: number) => boolean) {
    for (let x = 0; x < COLUMNS; x++) {
      for (let y = 0; y < ROWS; y++) {
        if (!visitor(this.cells[x][y], 0)) {
          return;
        };
      }
    }
  }

  /**
   * 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: Cell, range: number, includeCenter: boolean, visitor: (c: Cell, n: number) => boolean) {
    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)
   */
  find(filter: (c: Cell) => boolean): Cell | null {
    for (let x = 0; x < COLUMNS; x++) {
      for (let y = 0; y < ROWS; y++) {
        if (filter(this.cells[x][y])) {
          return this.cells[x][y];
        }
      }
    }
    return null;
  }

  /**
   * Find a random traversable cell with no agent present.
   */
  findRandomCell(): Cell {
    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;
  }

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

  /**
   * 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. The cell that fires the event is set on 
   * the event as `originCell`.
   */
  fireColorEvent(event: GameEvent, color: Color, origin: Cell) {
    if (color === NONE) {
      return;
    }
    const previousOrigin = event.originCell;
    event.originCell = origin;
    for (let x = 0; x < COLUMNS; x++) {
      for (let y = 0; y < ROWS; y++) {
        const cell = this.cells[x][y];
        cell.terrain.onColorEvent(event, color, cell);
        cell.agent?.onColorEvent(event, color, cell);
      }
    }
    event.originCell = previousOrigin;
  }
}