core/game.js

import { Board, ROWS, COLUMNS } from "./board.js";
import { Player } from "./player.js";
import { GameEvent } from "./event.js";
import { events } from "./events.js";
import { store, serializeBoard, deserializeBoard } from "./store.js";
import { animation } from "./animation.js";
import { loadBoard } from "../scenarios/loader.js";
import { Registry } from "./registry.js";
import { Item } from "./item.js";
import {
  PLAYER as PLAYER_FLAG,
  TRAVERSABLE,
  RANGED_WEAPON,
  VERTICAL,
  PARALYZED,
  TURNED_TO_STONE,
} from "./flags.js";
import {
  NORTH,
  SOUTH,
  EAST,
  WEST,
  UP,
  DOWN,
  ADJ_DIRECTIONS,
} from "./direction.js";
import { InFlightItem } from "../pieces/effects/effects.js";
import {
  findPathToTarget,
  getDirectionToCell,
  getDistance,
} from "../pieces/agents/targeting.js";

export const STATE = {
  MENU: "menu",
  PLAYING: "playing",
  ANIMATING: "animating",
  GAME_OVER: "game_over",
  WON: "won",
};

class Game {
  constructor() {
    /** @type {Player|null} */
    this.player = null;
    /** @type {Board|null} */
    this.board = null;
    /** @type {BoardView|null} */
    this.boardView = null;
    this.state = STATE.MENU;

    /** Fired when the game transitions to GAME_OVER. */
    this.onGameOver = null;
    /** Fired when the game transitions to WON. */
    this.onWon = null;

    // Listen for player death
    events.onPlayerChanged((player) => {
      if (this.state === STATE.PLAYING && player.health <= 0) {
        this._transitionTo(STATE.GAME_OVER);
        if (this.onGameOver) this.onGameOver();
      }
    });

    // Handle pit-trap fall-through: navigate to the board below (DOWN direction)
    events.onFallThrough((x, y) => {
      if (this.state === STATE.PLAYING) {
        const cell = this.board?.getCellAt(x, y);
        if (cell) this._navigateBoard(DOWN, cell);
      }
    });
  }

  /**
   * Start a brand-new game.
   * @param {string} boardPath  e.g. "tutorial/start"
   * @param {string} scenarioURL  base URL for scenario assets
   */
  async newGame(boardPath, scenarioURL) {
    const { board, startX, startY, startInv } = await loadBoard(boardPath);

    const player = new Player("Player", scenarioURL, boardPath, startX, startY);
    player.bag.setEventFactory(() => new GameEvent(player, this.board));

    // Grant starting inventory
    for (const key of startInv) {
      const item = Registry.get(key);
      if (item instanceof Item) {
        player.bag.add(item);
      } else {
        console.warn(`could not find ${key} to add to player inventory`);
      }
    }
    this._setContext(player, board);

    // Place the player
    const startCell = board.getCellAt(startX, startY);
    if (startCell) {
      startCell.setAgent(player);
    }
    this._transitionTo(STATE.PLAYING);
    this._fireFull();
  }

  /**
   * Load a saved game.
   * @returns {boolean} true if loaded successfully
   */
  async loadGame(slotName) {
    const save = await store.load(slotName);
    if (!save) return false;

    const pd = save.player;
    const player = new Player(
      pd.name ?? "Player",
      pd.scenarioURL ?? "",
      pd.boardID ?? "",
      pd.startX ?? 0,
      pd.startY ?? 0,
    );
    player.bag.setEventFactory(() => new GameEvent(player, this.board));

    // Restore unsaved maps
    player.unsavedMaps = new Map(pd.unsavedMaps ?? []);

    // Restore player health and status flags
    player.health = pd.health ?? 255;
    // Restore extra status flags (preserve built-in PLAYER|ORGANIC|NOT_EDITABLE)
    const MASK = 0b1111111; // low 7 bits are immutable
    player.flags = (player.flags & MASK) | ((pd.flags ?? 0) & ~MASK);

    // Restore bag
    player.bag._entries.length = 1; // keep EMPTY_HANDED
    for (const e of pd.bagEntries ?? []) {
      const item = Registry.get(e.key);
      if (item instanceof Item) {
        for (let i = 0; i < (e.count ?? 1); i++) player.bag.add(item);
      }
    }
    player.bag.setInitialSelection(pd.bagSelectedIndex ?? 0);

    // Load board from unsavedMaps (already visited state)
    const boardID = pd.boardID;
    let board;
    const savedBoardJSON = player.unsavedMaps.get(boardID);
    if (savedBoardJSON) {
      board = new Board();
      deserializeBoard(JSON.parse(savedBoardJSON), board);
    } else {
      // Fall back to fresh load if state not in unsavedMaps
      const result = await loadBoard(boardID);
      board = result.board;
    }

    this._setContext(player, board);

    // Re-place player at their last position
    const px = pd.playerX ?? board.startX;
    const py = pd.playerY ?? board.startY;
    const cell = board.getCellAt(px, py);
    if (cell) cell.setAgent(player);

    this._transitionTo(STATE.PLAYING);
    this._fireFull();
    return true;
  }

  /** @private */
  _setContext(player, board) {
    this.player = player;
    this.board = board;
    animation.setContext(board, player);
    // why are we doing this?
    if (this.boardView) {
      this.boardView.detach();
      this.boardView.attach(board);
    }
    animation.start();

    animation.onProjectileLanded = (event, _cell, _projectile) => {
      this._runAgentTurns(event);
    };
  }

  /** @private */
  _transitionTo(newState) {
    this.state = newState;
  }

  /**
   * Handle a player action.
   * @param {string} action
   * @param {Direction} [dir]
   */
  async handleInput(action, dir) {
    if (this.state !== STATE.PLAYING) return;

    switch (action) {
      case "move":
        if (dir) await this._movePlayer(dir);
        break;
      case "pickup":
        this._pickup();
        break;
      case "drop":
        this._drop(dir);
        break;
      case "use":
        this._useSelected();
        break;
      case "throw":
        if (dir) this._throwItem(dir);
        break;
      case "fire":
        if (dir) this._fireRanged(dir);
        break;
      case "vertical":
        await this._verticalMove();
        break;
      case "cycle-up":
        this.player.bag.selectUp();
        break;
      case "cycle-down":
        this.player.bag.selectDown();
        break;
      case "select-weapon":
        this.player.bag.selectFirstWeapon();
        break;
      case "select-empty":
        this.player.bag.selectEmptyHanded();
        break;
      case "reorder-up":
        this.player.bag.moveSelectedUp();
        break;
      case "reorder-down":
        this.player.bag.moveSelectedDown();
        break;
      case "pickup-index":
        this._pickupAtIndex(dir);
        break;
    }
    events.fireHandleInventoryMessaging();
  }

  // ── Movement ────────────────────────────────────────────────────────────────

  /** @private */
  async _movePlayer(dir) {
    const { player, board } = this;
    // Block movement while the player is paralyzed or turned to stone.
    if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
    const fromCell = board.getCurrentCell();
    if (!fromCell) return;

    const event = new GameEvent(player, board);

    // Ask current terrain if we may leave
    fromCell.terrain?.onExit(event, player, fromCell, dir);
    if (event.isCancelled) {
      this._showCancelMessage(event);
      return;
    }

    const toCell = board.getAdjacentCell(fromCell.x, fromCell.y, dir);

    if (!toCell) {
      // Player walked off the board edge — try to navigate to adjacent board
      await this._navigateBoard(dir, fromCell);
      return;
    }

    // Melee: walk into a non-player agent
    if (toCell.agent && !toCell.agent.is(PLAYER_FLAG)) {
      const meleeEvent = this._meleeAttack(fromCell, toCell, dir);
      // If the agent was pushed out (cell now empty) and the event wasn't
      // cancelled, move the player into the vacated cell (mirrors Java move()).
      if (
        !meleeEvent.isCancelled &&
        !toCell.agent &&
        toCell.canEnter(fromCell, player, dir)
      ) {
        fromCell.moveAgentTo(toCell, player);
        this._notifyAdjacent(toCell);
      }
      this._runAgentTurns(event);
      return;
    }

    // Let target terrain respond (open door, bounce message, etc.)
    const enterEvent = new GameEvent(player, board);
    toCell.terrain?.onEnter(enterEvent, player, toCell, dir);
    if (enterEvent.isCancelled) {
      this._showCancelMessage(enterEvent);
      return;
    }

    // Physical traversability check
    if (!toCell.canEnter(fromCell, player, dir)) return;

    // Notify terrain that is no longer adjacent
    this._notifyNotAdjacent(fromCell);

    // Move
    fromCell.moveAgentTo(toCell, player);

    // Notify stepped-on items
    const stepEvent = new GameEvent(player, board);
    toCell.onSteppedOn(stepEvent, fromCell, player);

    // Notify adjacent terrain
    this._notifyAdjacent(toCell);

    this._runAgentTurns(new GameEvent(player, board));
  }

  /** @private */
  _notifyAdjacent(cell) {
    const event = new GameEvent(this.player, this.board);
    for (const dir of ADJ_DIRECTIONS) {
      const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
      if (adj?.terrain) {
        adj.terrain.onAdjacentTo(event, adj);
      }
    }
  }

  /** @private */
  _notifyNotAdjacent(cell) {
    const event = new GameEvent(this.player, this.board);
    if (cell.terrain) cell.terrain.onNotAdjacentTo(event, cell);
    for (const dir of ADJ_DIRECTIONS) {
      const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
      if (adj?.terrain) {
        adj.terrain.onNotAdjacentTo(event, adj);
      }
    }
  }

  // ── Board navigation ────────────────────────────────────────────────────────

  /** @private */
  async _navigateBoard(dir, fromCell) {
    const adjacentPath = this.board.getAdjacentBoard(dir);
    if (!adjacentPath) return;

    const { player, board: oldBoard } = this;

    // Serialize current board state
    player.unsavedMaps.set(
      oldBoard.boardID,
      JSON.stringify(serializeBoard(oldBoard)),
    );

    // Remove player from current board
    fromCell.removeAgent(player);

    // Load or restore the new board
    let newBoard;
    const savedJSON = player.unsavedMaps.get(adjacentPath);
    if (savedJSON) {
      newBoard = new Board();
      deserializeBoard(JSON.parse(savedJSON), newBoard);
    } else {
      const result = await loadBoard(adjacentPath);
      newBoard = result.board;
    }

    // Determine entry position
    let entryX, entryY;
    if (dir === UP || dir === DOWN) {
      // Stairs: land at the same (x,y) on the destination board — Java convention.
      // The map designer places matching stairs at the same coordinates on each floor.
      entryX = fromCell.x;
      entryY = fromCell.y;
    } else if (dir === NORTH) {
      entryX = fromCell.x;
      entryY = ROWS - 1;
    } else if (dir === SOUTH) {
      entryX = fromCell.x;
      entryY = 0;
    } else if (dir === EAST) {
      entryX = 0;
      entryY = fromCell.y;
    } else if (dir === WEST) {
      entryX = COLUMNS - 1;
      entryY = fromCell.y;
    } else {
      entryX = newBoard.startX;
      entryY = newBoard.startY;
    }

    player.boardID = adjacentPath;
    this._setContext(player, newBoard);

    const entryCell = newBoard.getCellAt(entryX, entryY);
    if (entryCell) {
      if (entryCell.agent) {
        // Fallback: find nearest empty traversable cell
        const fallback = newBoard.findRandomCell();
        fallback.setAgent(player);
      } else {
        entryCell.setAgent(player);
      }
    }

    this._fireFull();
  }

  /**
   * Teleport the player to a specific board and position.
   * Called by Player.teleport() after the fade animation completes.
   * Mirrors Java Game.teleport(): saves current board, loads target, places player.
   * @param {string} boardID
   * @param {number} x
   * @param {number} y
   */
  async teleportTo(boardID, x, y) {
    const { player, board: oldBoard } = this;

    // Resolve relative boardID using the current board's directory, falling
    // back to board.folder when the boardID has no directory component.
    const boardDir = oldBoard.boardID.includes("/")
      ? oldBoard.boardID.split("/").slice(0, -1).join("/")
      : (oldBoard.folder ?? "");
    const fullBoardID = boardDir ? `${boardDir}/${boardID}` : boardID;

    // Save current board state
    player.unsavedMaps.set(
      oldBoard.boardID,
      JSON.stringify(serializeBoard(oldBoard)),
    );

    // Load or restore the target board
    let newBoard;
    const savedJSON = player.unsavedMaps.get(fullBoardID);
    if (savedJSON) {
      newBoard = new Board();
      deserializeBoard(JSON.parse(savedJSON), newBoard);
    } else {
      const result = await loadBoard(fullBoardID);
      newBoard = result.board;
    }

    player.boardID = fullBoardID;
    player.startX = x;
    player.startY = y;
    this._setContext(player, newBoard);

    const entryCell = newBoard.getCellAt(x, y);
    if (entryCell) {
      if (entryCell.agent) {
        const fallback = newBoard.findRandomCell();
        fallback?.setAgent(player);
      } else {
        entryCell.setAgent(player);
      }
    }

    this._fireFull();
  }

  /** @private */
  async _verticalMove() {
    const cell = this.board.getCurrentCell();
    if (!cell?.terrain?.is(VERTICAL)) return;

    const { player, board } = this;

    // Determine direction: try UP first (stair exit cancels wrong dir)
    for (const dir of [UP, DOWN]) {
      const adjPath = board.getAdjacentBoard(dir);
      if (!adjPath) continue;

      // Check if terrain allows exit in this direction
      const testEvent = new GameEvent(player, board);
      cell.terrain.onExit(testEvent, player, cell, dir);
      if (!testEvent.isCancelled) {
        await this._navigateBoard(dir, cell);
        return;
      }
    }
  }

  /** @private */
  _pickup() {
    this._pickupAtIndex(0);
  }

  /**
   * Pick up the item at the given display-list index on the current cell.
   * Index 0 = first item in the grouped display list (same as pressing 'p').
   * @private
   * @param {number} index
   */
  _pickupAtIndex(index) {
    const { player, board } = this;
    const cell = board.getCurrentCell();
    if (!cell || cell.isBagEmpty) return;

    const item = _itemAtDisplayIndex(cell, index);
    if (!item) return;

    const event = new GameEvent(player, board);

    // Let terrain react (e.g., deny pickup on lava)
    cell.terrain?.onPickup(event, cell, player, item);
    if (event.isCancelled) {
      this._showCancelMessage(event);
      return;
    }

    // Weakness check
    if (player.enforceWeakness(event, cell, item)) return;

    cell.removeItem(item);
    player.bag.add(item);

    this._runAgentTurns(event);
  }

  /** @private */
  _drop(dir) {
    const { player, board } = this;
    const cell = board.getCurrentCell();
    if (!cell) return;

    const item = player.bag.getSelected();
    if (!item || item.name === "Empty-handed") return;

    // Drop in direction if dir given, else on current cell
    let targetCell = cell;
    if (dir) {
      const adj = board.getAdjacentCell(cell.x, cell.y, dir);
      if (adj && adj.terrain?.is?.(TRAVERSABLE)) targetCell = adj;
    }

    const event = new GameEvent(player, board);
    targetCell.terrain?.onDrop(event, targetCell, item);
    if (event.isCancelled) {
      // Item vanishes into lava etc. — still remove from bag
      player.bag.remove(item);
      return;
    }

    player.bag.remove(item);
    targetCell.addItem(item);

    this._runAgentTurns(event);
  }

  /** @private */
  _useSelected() {
    const { player, board } = this;
    const cell = board.getCurrentCell();
    if (!cell) return;

    const item = player.bag.getSelected();
    if (!item || item.name === "Empty-handed") return;

    const event = new GameEvent(player, board);
    item.onUse(event, cell, player);
    if (event.isCancelled) {
      this._showCancelMessage(event);
      return;
    }

    this._runAgentTurns(event);
  }

  /** @private */
  _throwItem(dir) {
    const { player, board } = this;
    const cell = board.getCurrentCell();
    if (!cell) return;

    const item = player.bag.getSelected();
    if (!item || item.name === "Empty-handed") return;

    player.bag.remove(item);
    const projectile = new InFlightItem(item, dir, player);
    cell.addEffect(projectile);
  }

  /** @private */
  _fireRanged(dir) {
    const { player, board } = this;
    if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
    const cell = board.getCurrentCell();
    if (!cell) return;

    const weapon = player.bag.getSelected();
    if (!weapon || !weapon.is(RANGED_WEAPON)) {
      events.fireMessage(cell, "You need a ranged weapon equipped.");
      return;
    }

    const event = new GameEvent(player, board);
    const ammoItem = weapon.onFire(event);
    if (event.isCancelled) {
      this._showCancelMessage(event);
      return;
    }
    if (!ammoItem) {
      events.fireMessage(cell, "You have no ammunition.");
      return;
    }

    const projectile = new InFlightItem(ammoItem, dir, player);
    cell.addEffect(projectile);
  }

  // ── Combat ──────────────────────────────────────────────────────────────────

  /** @private */
  _meleeAttack(attackerCell, targetCell, dir) {
    const { player, board } = this;
    const event = new GameEvent(player, board);
    const target = targetCell.agent;

    // Player hits target
    player.onHit(event, attackerCell, targetCell, target);

    // Target reacts to being hit — pass attackerCell (Java convention: agentLoc = attacker's cell)
    target.onHitBy(event, attackerCell, player, dir);

    // Remove any agents killed by the attack
    if (event.kills) {
      for (const { cell, agent } of event.kills) {
        agent.onDie(event, cell);
        cell.removeAgent(agent);
      }
    }

    if (event.isCancelled) {
      this._showCancelMessage(event);
    }
    return event;
  }

  // ── Agent movement ──────────────────────────────────────────────────────────

  /**
   * Move an agent according to a Targeting specification.
   * Called by agent onFrame handlers.
   */
  createEvent() {
    return new GameEvent(this.player, this.board);
  }

  agentMove(cell, agent, targeting) {
    if (!this.board) return;
    const dir = findPathToTarget(this.board, cell, agent, targeting);
    if (dir != null) {
      const event = this.createEvent();
      this.agentMoveInDirection(event, cell, agent, dir);
    }
  }

  /**
   * Execute an agent move in a specific direction (for RollingBoulder, Pusher, Tumbleweed).
   */
  agentMoveInDirection(event, cell, agent, dir) {
    if (!this.board) return;
    const next = this.board.getAdjacentCell(cell.x, cell.y, dir);

    cell.terrain?.onAgentExit(event, agent, cell, dir);
    if (event.isCancelled) return;

    if (!next) {
      event.cancel();
      return;
    }

    // Check terrain traversability for the agent (mirrors Java onAgentEnter cancel)
    if (next.terrain && !next.terrain.canEnter(agent, next, dir)) {
      event.cancel();
      return;
    }

    next.terrain?.onAgentEnter(event, agent, next, dir);
    if (event.isCancelled) return;

    const nextAgent = next.agent;
    if (nextAgent != null) {
      if (nextAgent.is(PLAYER_FLAG)) {
        agent.onHit(event, cell, next, nextAgent);
      }
      nextAgent.onHitBy(event, cell, agent, dir);
      if (event.isCancelled) return;
    }

    const moving = cell.agent;
    if (moving) {
      cell.moveAgentTo(next, moving);
      if (!next.isBagEmpty) next.onSteppedOn(event, next, next.agent);
    }
  }

  /**
   * Fire a projectile from an agent toward the player if within targeting range.
   */
  agentShoot(cell, agent, ammo, targeting) {
    if (!this.board || !ammo) return;
    const playerCell = this.board.getCurrentCell();
    if (!playerCell) return;

    const d = getDistance(cell, playerCell);
    if (d > targeting._range) return;

    const dir = getDirectionToCell(cell, playerCell);
    if (!dir) return;

    const event = this.createEvent();
    this.shoot(event, cell, agent, ammo, dir);
  }

  /**
   * Fire a projectile from a cell in a specific direction (used by terrain
   * like Shooter, and internally by agentShoot).
   * @param {GameEvent} event
   * @param {Cell} cell
   * @param {Piece} originator
   * @param {Item} ammo
   * @param {Direction} dir
   */
  shoot(event, cell, originator, ammo, dir) {
    if (!ammo || !dir) return;
    const projectile = new InFlightItem(ammo, dir, originator);
    cell.terrain?.onFlyOver(event, cell, projectile);
    if (!event.isCancelled) {
      cell.addEffect(projectile);
    }
  }

  // ── Agent AI turns ──────────────────────────────────────────────────────────

  /** @private */
  _runAgentTurns(event) {
    if (!this.board || !this.player) return;
    const board = this.board;
    const baseEvent = event ?? new GameEvent(this.player, board);

    // Collect all non-player agents
    const agentCells = [];
    board.visit((cell) => {
      if (cell.agent && !cell.agent.is(PLAYER_FLAG)) {
        agentCells.push(cell);
      }
    });

    for (const cell of agentCells) {
      const agent = cell.agent;
      if (!agent) continue;
      const agentEvent = new GameEvent(this.player, board);
      agent.onTurn(agentEvent, board, cell);
    }
  }

  // ── Save ────────────────────────────────────────────────────────────────────

  async save(slotName) {
    if (this.player && this.board) {
      await store.save(this.player, this.board, slotName);
    }
  }

  // ── Helpers ─────────────────────────────────────────────────────────────────

  /** @private */
  _showCancelMessage(event) {
    const msg = event._cancelMessage;
    const cell = event._cancelCell ?? this.board?.getCurrentCell();
    if (msg) events.fireMessage(cell, msg);
  }

  /** @private */
  _fireFull() {
    if (this.player) {
      events.firePlayerChanged(this.player);
      events.fireInventoryChanged(this.player.bag);
      events.fireFlagsChanged(this.player);
    }
    if (this.boardView) {
      this.boardView.rerender?.();
    }
  }
}

export const game = new Game();

/**
 * Return the item at visual display index `index` in the grouped item list for
 * the given cell. Groups items by name (matching popup display order).
 * Index 0 = first item shown in the popup (pressing '1' or 'p').
 * @param {Cell} cell
 * @param {number} index
 * @returns {Item|null}
 */
function _itemAtDisplayIndex(cell, index) {
  const seen = new Set();
  let count = 0;
  for (const item of cell.items) {
    if (!seen.has(item.name)) {
      if (count === index) return item;
      seen.add(item.name);
      count++;
    }
  }
  return null;
}