core/player.ts

import { Agent } from "./agent.ts";
import { Color, GREEN, RED, NONE } from "./color.ts";
import { events } from "./event-bus.ts";
import { GameEvent } from "./game-event.ts";
import { hasAmmo, MirrorShield, Parabullet, Stoneray } from "../pieces/items/items.ts";
import { Item } from "./item.ts";
import { Sym } from "./sym.ts";
import {
  getFlag,
  NOT_EDITABLE,
  ORGANIC,
  PLAYER,
  POISONED,
  WEAK,
  WEAPON,
} from "./flags.ts";
import { Board } from "./board.ts";
import { Cell } from "./cell.ts";
import { Direction } from "./direction.ts";
import { Fade } from "../pieces/effects/effects.ts";
import { game } from "./game.ts";

export const MAX_HEALTH = 255;

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

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

function playerColor(outside: boolean, health: number): Color {
  return outside
    ? new Color("", `rgb(${MAX_HEALTH - health},0,0)`, false)
    : new Color("", `rgb(${MAX_HEALTH},${health},${health})`, false);
}

function playerBg(player: Player): Color | null {
  if (player.isHealing) {
    return GREEN;
  } else if (player.isDamaged) {
    return RED;
  }
  return null;
}

/**
 * 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 extends Sym {
  #player: Player;
  backgroundColor: Color;
  constructor(player: Player) {
    super(
      "@",
      playerColor(false, player.health),
      playerBg(player),
      playerColor(true, player.health),
      playerBg(player),
    );
    this.#player = player;
    this.entity = "@";
    this.color = NONE;
    this.backgroundColor = NONE;
  }
  getColor(outside: boolean): Color | null {
    const hex = playerColor(outside, this.#player.health);
    if (hex === null) {
      return null;
    }
    this.color = hex;
    return this.color;
  }
  getBackground(outside: boolean): Color | null {
    const hex = playerBg(this.#player);
    if (hex === null) return null;
    this.backgroundColor = hex;
    return this.backgroundColor;
  }
}

type Entry = {
  piece: Item;
  count: number;
  ammo: number;
}

/**
 * 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 {
  #entries: Entry[];
  #createEvent: (() => GameEvent) | undefined;
  selectedIndex: number;
  constructor() {
    this.#entries = [{ piece: EMPTY_HANDED, count: 1, ammo: 0 }];
    this.selectedIndex = 0;
  }

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

  setEventFactory(fn: () => GameEvent) {
    this.#createEvent = fn;
  }

  createEvent(): GameEvent {
    if (typeof this.#createEvent !== "function") {
      throw new Error("Event factory not set");
    }
    return this.#createEvent();
  }

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

  /** True if this entry is the selected one. */
  isSelected(entry: Entry) {
    return this.#entries[this.selectedIndex] === entry;
  }

  selectUp() {
    this.#changeIndex(-1);
  }

  selectDown() {
    this.#changeIndex(1);
  }

  selectEmptyHanded() {
    this.#changeSelection(0);
  }

  select(index: number) {
    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(item: 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);
  }

  addAt(index: number, item: 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);
  }

  remove(item: 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);
  }

  contains(item: Item) {
    return this.findEntry(item) != null;
  }

  /**
   * Returns the first item matching predicate fn, or null.
   */
  find(fn: (i: Item) => boolean) {
    for (const entry of this.#entries) {
      if (fn(entry.piece)) return entry.piece;
    }
    return null;
  }

  getByName(name: string): Item | null {
    for (const entry of this.#entries) {
      if (entry.piece.name === name) return entry.piece;
    }
    return null;
  }

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

  size() {
    return this.#entries.length;
  }

  get entries(): Entry[] {
    return this.#entries;
  }

  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.
   */
  exchange(item: Item): Item | null {
    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).
   */
  setInitialSelection(index: number) {
    this.selectedIndex = Math.max(
      0,
      Math.min(index, this.#entries.length - 1),
    );
  }

  findEntry(piece: Item) {
    return this.#entries.find((e) => e.piece === piece) ?? null;
  }

  #getIndex(piece: Item) {
    return this.#entries.findIndex((e) => e.piece === piece);
  }

  #changeIndex(delta: number) {
    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: number) {
    if (newIndex < 0 || newIndex >= this.#entries.length) return;
    if (typeof this.#createEvent !== "function") {
      return;
    }
    const event = this.#createEvent();
    const cell = event.board?.getCurrentCell()

    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: 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 (hasAmmo(item)) {
      // 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 (hasAmmo(e.piece) && 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: 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 (!hasAmmo(item)) {
      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 {
  #healCountDown: number;
  #damageCountDown: number;
  bag: PlayerBag;
  scenarioURL: string;
  boardID: string;
  health: number;
  startX: number;
  startY: number;
  unsavedMaps: Map<any, any>;
  constructor(name: string, scenarioURL: string, boardID: string, startX: number, startY: number) {
    // 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, Sym.of("@", NONE));
    this.symbol = new PlayerSymbol(this);
    this.health = MAX_HEALTH;
    this.bag = new PlayerBag();
    this.scenarioURL = scenarioURL;
    this.boardID = boardID;
    this.startX = startX;
    this.startY = startY;
    this.unsavedMaps = new Map();
    this.#damageCountDown = 0;
    this.#healCountDown = 0;
  }

  get isHealing(): boolean {
    return this.#healCountDown > 0;
  }

  get isDamaged(): boolean {
    return this.#damageCountDown > 0;
  }

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

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

  setHealth(h: number) {
    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.
   */
  changeHealth(delta: number): number {
    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;
      const cell = game.board?.getCurrentCell();
      cell.board.notifyCellChange(cell);
    } else if (this.health > old) {
      this.#healCountDown = 2;
      const cell = game.board?.getCurrentCell();
      cell.board.notifyCellChange(cell);
    }
    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() {
    return true;
  }

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

  onHitBy(event: GameEvent, _agentLoc: Cell, _agent: Agent, _dir: Direction) {
    event.cancel();
  }

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

  /**
   * Called each animation tick. Counts down the damage/heal flash and
   * triggers a re-render when they expire.
   */
  onFrame(cell: Cell) {
    if (this.#damageCountDown > 0 && --this.#damageCountDown === 0) {
      cell.board.notifyCellChange(cell);
    }
    if (this.#healCountDown > 0 && --this.#healCountDown === 0) {
      cell.board.notifyCellChange(cell);
    }
  }

  /**
   * Test a resistance flag with a 15% chance of losing it permanently.
   */
  testResistance(resistance: number) {
    if (this.is(resistance)) {
      if (Math.random() < 0.15) {
        this.remove(resistance);
      }
      return true;
    }
    return false;
  }

  cureWeakness(event: GameEvent, healer: Item) {
    if (this.not(WEAK)) {
      event.cancel("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: GameEvent, healer: Item) {
    if (this.not(POISONED)) {
      event.cancel("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.
   */
  heal(event: GameEvent, healer: Item, amount: number, board: Board) {
    const cell = board.getCurrentCell();
    if (this.is(POISONED)) {
      event.cancel("You can't heal, you're poisoned.", cell);
    } else if (this.health === MAX_HEALTH) {
      event.cancel("You're at full health already; let's skip it.", cell);
    } else {
      const old = this.health;
      this.changeHealth(-amount); // negative delta = heal
      const gained = this.health - old;
      events.fireMessage(`Healed ${gained} points.`, cell);
      this.bag.remove(healer);
    }
  }

  /**
   * If the player is WEAK, enforce the single-item inventory limit.
   */
  enforceWeakness(event: GameEvent, loc: Cell, item: Item): boolean {
    if (this.not(WEAK) || this.bag.size() <= 1) return false;
    event.cancel("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.
   */
  weaken(cell: 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("You are weak; you can only hold one item at a time", cell);
    }
  }

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