core/player.js

import { Agent } from "./agent.js";
import { Item } from "./item.js";
import { GameEvent } from "./event.js";
import { events } from "./events.js";
import { GREEN, RED, NONE } from "./color.js";
import { Symbol } from "./symbol.js";
import { MirrorShield, Parabullet, Stoneray } from "../pieces/items/items.js";
import {
  PLAYER,
  NOT_EDITABLE,
  ORGANIC,
  WEAK,
  POISONED,
  WEAPON,
  getFlag,
} from "./flags.js";
// game is imported lazily to avoid circular dep issues at module init time
import { game } from "./game.js";
import { Fade } from "../pieces/effects/effects.js";

export const MAX_HEALTH = 255;

// ── EmptyHanded (the "no item" sentinel) ─────────────────────────────────────

class EmptyHanded extends Item {
  constructor() {
    super("Empty-handed", 0, NONE, Symbol.of(" ", NONE));
  }
}

/**
 * Singleton representing the player wielding nothing.
 * Always lives at index 0 in the PlayerBag.
 */
export const EMPTY_HANDED = new EmptyHanded();

// ── PlayerSymbol ──────────────────────────────────────────────────────────────

/**
 * A live symbol whose color reflects the player's current health and
 * damage/heal animation state.
 *
 * Inside (underground):
 *   fg = rgb(255, health, health) — white at full health, red when dying
 * Outside (above ground):
 *   fg = rgb(255-health, 0, 0)   — black at full health, red when dying
 * Background:
 *   GREEN during heal flash, RED during damage flash, otherwise none
 */
class PlayerSymbol {
  /** @param {Player} player */
  constructor(player) {
    this._player = player;
    this.entity = "@";
  }

  /** @param {boolean} outside */
  getColor(outside) {
    const h = this._player.health;
    if (outside) {
      return `rgb(${MAX_HEALTH - h},0,0)`;
    }
    return `rgb(${MAX_HEALTH},${h},${h})`;
  }

  /** @param {boolean} _outside */
  getBackground(_outside) {
    if (this._player._healCountDown > 0) return GREEN;
    if (this._player._damageCountDown > 0) return RED;
    return null;
  }
}

// ── PlayerBag ─────────────────────────────────────────────────────────────────

/**
 * The player's inventory bag. Always contains EMPTY_HANDED at index 0.
 * One entry is "selected" — the currently wielded item.
 *
 * Entries are {piece, count, ammo} objects (not Piece subclasses).
 */
export class PlayerBag {
  constructor() {
    /** @type {Array<{piece: Item, count: number, ammo: number}>} */
    this._entries = [{ piece: EMPTY_HANDED, count: 1, ammo: 0 }];
    this._selectedIndex = 0;
    /**
     * Injected by the Game controller after creation.
     * Should return a GameEvent for the current turn.
     * @type {function(): GameEvent}
     */
    this._createEvent = () => new GameEvent(null, null);
  }

  last() {
    const item = this._entries[this._entries.length - 1].piece;
    if (item instanceof EmptyHanded) {
      return null;
    }
    return item;
  }

  /** @param {function(): GameEvent} fn */
  setEventFactory(fn) {
    this._createEvent = fn;
  }

  // ── Selection ─────────────────────────────────────────────────────────────

  /** The currently selected item (or EMPTY_HANDED). */
  getSelected() {
    return this._entries[this._selectedIndex].piece;
  }

  /** True if this entry is the selected one. */
  isSelected(entry) {
    return this._entries[this._selectedIndex] === entry;
  }

  selectUp() {
    this._changeIndex(-1);
  }

  selectDown() {
    this._changeIndex(1);
  }

  selectEmptyHanded() {
    this._changeSelection(0);
  }

  select(index) {
    this._changeSelection(index);
  }

  selectFirstWeapon() {
    for (let i = 1; i < this._entries.length; i++) {
      if (this._entries[i].piece.is(WEAPON)) {
        this._changeSelection(i);
        break;
      }
    }
  }

  // ── Add / remove ──────────────────────────────────────────────────────────

  /** @param {Item} item */
  add(item) {
    const entry = this._findEntry(item);
    if (entry) {
      entry.count++;
    } else {
      this._entries.push({ piece: item, count: 1, ammo: 0 });
    }
    this._combineAmmoIfNecessary(item);
    events.fireInventoryChanged(this);
  }

  /** @param {Item} item */
  addAt(index, item) {
    const entry = this._findEntry(item);
    if (entry) {
      entry.count++;
    } else {
      this._entries.splice(index, 0, { piece: item, count: 1, ammo: 0 });
      if (index <= this._selectedIndex) this._selectedIndex++;
    }
    events.fireInventoryChanged(this);
  }

  /** @param {Item} item */
  remove(item) {
    if (item === EMPTY_HANDED) return;
    const entry = this._findEntry(item);
    if (!entry) return;

    if (entry === this._entries[this._selectedIndex] && entry.count <= 1) {
      this.selectUp();
    }
    this._splitAmmoIfNecessary(item);
    entry.count--;
    if (entry.count <= 0) {
      const i = this._entries.indexOf(entry);
      this._entries.splice(i, 1);
      if (this._selectedIndex >= this._entries.length) {
        this._selectedIndex = this._entries.length - 1;
      }
    }
    events.fireInventoryChanged(this);
  }

  // ── Query ─────────────────────────────────────────────────────────────────

  /** @param {Item} item */
  contains(item) {
    return this._findEntry(item) != null;
  }

  /**
   * Returns the first item matching predicate fn, or null.
   * @param {function(Item): boolean} fn
   */
  find(fn) {
    for (const entry of this._entries) {
      if (fn(entry.piece)) return entry.piece;
    }
    return null;
  }

  /** @param {string} name  item name or type name */
  getByName(name) {
    for (const entry of this._entries) {
      if (entry.piece.name === name) return entry.piece;
    }
    return null;
  }

  getCount(item) {
    const entry = this._findEntry(item);
    return entry ? entry.count : 0;
  }

  size() {
    return this._entries.length;
  }

  /** All entries (read-only view). @returns {Array} */
  get entries() {
    return this._entries;
  }

  // ── Reordering ────────────────────────────────────────────────────────────

  moveSelectedUp() {
    const sel = this._entries[this._selectedIndex];
    if (sel.piece === EMPTY_HANDED) return;
    let i = this._selectedIndex;
    this._entries.splice(i, 1);
    i = i - 1 < 1 ? this._entries.length : i - 1;
    this._entries.splice(i, 0, sel);
    this._selectedIndex = i;
    events.fireInventoryChanged(this);
  }

  moveSelectedDown() {
    const sel = this._entries[this._selectedIndex];
    if (sel.piece === EMPTY_HANDED) return;
    let i = this._selectedIndex;
    this._entries.splice(i, 1);
    i = i + 1 > this._entries.length ? 1 : i + 1;
    this._entries.splice(i, 0, sel);
    this._selectedIndex = i;
    events.fireInventoryChanged(this);
  }

  /**
   * Exchange the current selection for a new item.
   * @param {Item} item
   * @returns {Item|null} the item given up
   */
  exchange(item) {
    const sel = this._entries[this._selectedIndex];
    if (sel.piece === EMPTY_HANDED) return null;
    const old = sel.piece;
    this.remove(old);
    this.add(item);
    this._changeSelection(this._getIndex(item));
    return old;
  }

  /**
   * Quietly set the selection index (during deserialization, no events).
   * @param {number} index
   */
  setInitialSelection(index) {
    this._selectedIndex = Math.max(
      0,
      Math.min(index, this._entries.length - 1),
    );
  }

  // ── Private ───────────────────────────────────────────────────────────────

  _findEntry(piece) {
    return this._entries.find((e) => e.piece === piece) ?? null;
  }

  _getIndex(piece) {
    return this._entries.findIndex((e) => e.piece === piece);
  }

  _changeIndex(delta) {
    let i = this._selectedIndex + delta;
    if (i < 0) i = this._entries.length - 1;
    else if (i >= this._entries.length) i = 0;
    this._changeSelection(i);
  }

  _changeSelection(newIndex) {
    if (newIndex < 0 || newIndex >= this._entries.length) return;
    const event = this._createEvent();
    const cell = event.board?.getCurrentCell() ?? null;

    const oldItem = this._entries[this._selectedIndex].piece;
    oldItem.onDeselect(event, cell);
    if (event.isCancelled) return;

    const newEntry = this._entries[newIndex];
    newEntry.piece.onSelect(event, cell);
    if (event.isCancelled) return;

    this._selectedIndex = newIndex;
    events.fireInventoryChanged(this);
  }

  _combineAmmoIfNecessary(item) {
    // Duck-typed ConsumesAmmo: weapon has getAmmoType()
    // Duck-typed ProvidesAmmo: ammo item is AMMUNITION and some weapon in bag has getAmmoType() === item
    let weapon = null;
    let ammo = null;

    if (typeof item.getAmmoType === "function") {
      // item is the weapon
      weapon = item;
      ammo = item.getAmmoType();
    } else {
      // item might be ammo for a weapon already in the bag
      for (const e of this._entries) {
        if (
          typeof e.piece.getAmmoType === "function" &&
          e.piece.getAmmoType() === item
        ) {
          weapon = e.piece;
          ammo = item;
          break;
        }
      }
    }

    if (weapon === null || ammo === null) return;

    const weaponEntry = this._findEntry(weapon);
    const ammoEntry = this._findEntry(ammo);
    if (weaponEntry === null || ammoEntry === null) return;

    // Transfer all ammo counts into the weapon entry and remove the ammo item.
    weaponEntry.ammo += ammoEntry.count;
    ammoEntry.count = 0;
    const i = this._entries.indexOf(ammoEntry);
    if (i !== -1) this._entries.splice(i, 1);
    if (this._selectedIndex >= this._entries.length) {
      this._selectedIndex = this._entries.length - 1;
    }
  }

  _splitAmmoIfNecessary(item) {
    // When the last copy of a ConsumesAmmo weapon is removed and it has loaded
    // ammo, split that ammo back into individual items at the same position.
    if (typeof item.getAmmoType !== "function") return;

    const weaponEntry = this._findEntry(item);
    if (weaponEntry === null) return;
    if (weaponEntry.count !== 1 || weaponEntry.ammo <= 0) return;

    const ammoItem = item.getAmmoType();
    const index = this._entries.indexOf(weaponEntry);
    const ammoCount = weaponEntry.ammo;
    weaponEntry.ammo = 0;

    // Insert ammo items directly without triggering another combine cycle.
    for (let i = 0; i < ammoCount; i++) {
      const existing = this._findEntry(ammoItem);
      if (existing) {
        existing.count++;
      } else {
        this._entries.splice(index, 0, { piece: ammoItem, count: 1, ammo: 0 });
      }
    }
  }
}

/**
 * The player character. Extends Agent to participate in the normal
 * piece/cell/board model, but carries extra state (health, inventory,
 * navigation context).
 *
 * changeHealth(delta):
 *   positive delta = damage (health decreases)
 *   negative delta = heal  (health increases)
 *   returns current health after the change
 */
export class Player extends Agent {
  /**
   * @param {string} name
   * @param {string} scenarioURL   base URL of the current scenario
   * @param {string} boardID       path stem of the current board
   * @param {number} startX        default entry column
   * @param {number} startY        default entry row
   */
  constructor(name, scenarioURL, boardID, startX, startY) {
    // Pass a placeholder symbol to satisfy Piece's validation, then replace
    // it with the live PlayerSymbol that closes over this player instance.
    super(name, NOT_EDITABLE | ORGANIC | PLAYER, NONE, Symbol.of("@", NONE));
    this.symbol = new PlayerSymbol(this);
    this.health = MAX_HEALTH;
    this.bag = new PlayerBag();
    this.scenarioURL = scenarioURL ?? "";
    this.boardID = boardID ?? "";
    this.startX = startX ?? -1;
    this.startY = startY ?? -1;
    this.unsavedMaps = new Map();
    this._damageCountDown = 0;
    this._healCountDown = 0;
  }

  add(flag) {
    this.flags |= flag;
    events.fireFlagsChanged(this);
  }

  remove(flag) {
    this.flags &= ~flag;
    events.fireFlagsChanged(this);
  }

  setHealth(h) {
    this.health = h;
    events.firePlayerChanged(this);
  }

  /**
   * Apply a health delta. Positive = damage, negative = heal.
   * Returns current health. If health reaches 0, the game-over callback fires.
   * @param {number} delta
   * @returns {number} current health
   */
  changeHealth(delta) {
    if (delta === 0) return this.health;
    const old = this.health;
    this.health = Math.max(0, Math.min(MAX_HEALTH, this.health - delta));
    if (this.health < old) {
      this._damageCountDown = 2;
    } else if (this.health > old) {
      this._healCountDown = 2;
    }
    events.firePlayerChanged(this);
    if (this.health <= 0) {
      // Game-over is handled by the Game controller listening to playerChanged
      // and checking health === 0.
    }
    return this.health;
  }

  canEnter(_direction, _from, _to) {
    return true;
  }

  onHit(event, attackerLoc, agentLoc, agent) {
    const item = this.bag.getSelected();
    item.onHit(event, agentLoc, agent);
  }

  onHitBy(event, _agentLoc, _agent, _dir) {
    event.cancel();
  }

  onHitByItem(event, itemLoc, item, dir) {
    const selected = this.bag.getSelected();
    if (
      (item instanceof Parabullet || item instanceof Stoneray) &&
      selected instanceof MirrorShield
    ) {
      game.shoot(event, itemLoc, this, item, dir.reverse);
    }
  }

  /**
   * Called by Teleporter (and similar terrain) when the player steps on it.
   * Mirrors Java Player.teleport(): cancels the enter event, removes the player
   * from the current cell, plays a Fade animation on the adjacent cell in the
   * movement direction, then calls Game.teleportTo() when the fade completes.
   */
  teleport(event, dir, boardID, x, y) {
    event.cancel();
    const currentCell = event.board?.getCurrentCell?.();
    if (!currentCell) return;
    currentCell.removeAgent(this);
    const adjCell =
      event.board.getAdjacentCell(currentCell.x, currentCell.y, dir) ??
      currentCell;
    const fade = new Fade(this.symbol, () => game.teleportTo(boardID, x, y));
    adjCell.addEffect(fade);
    events.fireCellChanged(currentCell);
    events.fireCellChanged(adjCell);
  }

  // ── Animation frame ───────────────────────────────────────────────────────

  /**
   * Called each animation tick. Counts down the damage/heal flash and
   * triggers a re-render when they expire.
   * @param {Cell} cell
   */
  onFrame(cell) {
    if (this._damageCountDown > 0 && --this._damageCountDown === 0) {
      cell.board._notifyCellChange(cell);
    }
    if (this._healCountDown > 0 && --this._healCountDown === 0) {
      cell.board._notifyCellChange(cell);
    }
  }

  // ── Resistances ───────────────────────────────────────────────────────────

  /**
   * Test a resistance flag with a 15% chance of losing it permanently.
   * @param {number} resistance  flag constant (e.g. FIRE_RESISTANT)
   * @returns {boolean}
   */
  testResistance(resistance) {
    if (this.is(resistance)) {
      if (Math.random() < 0.15) {
        this.remove(resistance);
      }
      return true;
    }
    return false;
  }

  // ── Inventory helpers ─────────────────────────────────────────────────────

  cureWeakness(event, healer) {
    if (this.not(WEAK)) {
      event.cancelWithMessage("You feel pretty good right now, let's save it");
    } else {
      this.remove(WEAK);
      events.fireModalMessage("Your energy is restored.");
      this.bag.remove(healer);
    }
  }

  curePoison(event, healer) {
    if (this.not(POISONED)) {
      event.cancelWithMessage("If you were poisoned, this would have cured it");
    } else {
      this.remove(POISONED);
      events.fireModalMessage("The sick feeling goes away. ");
      this.bag.remove(healer);
    }
  }

  /**
   * Heal the player by amount. Consumes healer item if successful.
   * @param {GameEvent} event
   * @param {Item} healer
   * @param {number} amount  positive = healing
   * @param {Board} board
   */
  heal(event, healer, amount, board) {
    if (this.is(POISONED)) {
      event.cancelWithMessage("You can't heal, you're poisoned.");
    } else if (this.health === MAX_HEALTH) {
      event.cancelWithMessage("You're at full health already; let's skip it.");
    } else {
      const old = this.health;
      this.changeHealth(-amount); // negative delta = heal
      const gained = this.health - old;
      events.fireMessage(board.getCurrentCell(), `Healed ${gained} points.`);
      this.bag.remove(healer);
    }
  }

  /**
   * If the player is WEAK, enforce the single-item inventory limit.
   * @param {GameEvent} event
   * @param {Cell} loc
   * @param {Item} item
   * @returns {boolean} true if weakness enforcement fired
   */
  enforceWeakness(event, loc, item) {
    if (this.not(WEAK) || this.bag.size() <= 1) return false;
    event.cancelWithMessage(
      "You're weak, you can only hold one thing at a time",
    );
    const wasEmpty = this.bag.getSelected() === EMPTY_HANDED;
    if (wasEmpty) this.bag.select(1);
    const oldItem = this.bag.exchange(item);
    loc.removeItem(item);
    if (oldItem) loc.addItem(oldItem);
    if (wasEmpty) this.bag.selectEmptyHanded();
    return true;
  }

  /**
   * Apply the WEAK flag and drop all but the selected item.
   * @param {Cell} cell
   */
  weaken(cell) {
    if (this.not(WEAK)) {
      this.add(WEAK);
      const toRemove = this.bag.entries
        .filter((e) => !this.bag.isSelected(e) && e.piece !== EMPTY_HANDED)
        .slice();
      for (const entry of toRemove) {
        for (let i = 0; i < entry.count; i++) {
          this.bag.remove(entry.piece);
          cell.addItem(entry.piece);
        }
      }
      events.fireMessage(
        cell,
        "You are weak; you can only hold one item at a time",
      );
    }
  }

  /**
   * Check if a token matches a flag label or an item in the player's bag.
   * Used to evaluate scenario trigger conditions.
   * @param {string} token
   * @returns {boolean}
   */
  matchesFlagOrItem(token) {
    return (
      this.bag.getByName(token) != null || this.is(this._flagFromToken(token))
    );
  }

  /** @private */
  _flagFromToken(token) {
    return getFlag(token) ?? 0;
  }
}