pieces/agents/npcs.ts

import { Registry } from "../../core/registry.ts";
import { game } from "../../core/game.ts";
import { events } from "../../core/event-bus.ts";
import {
  ORGANIC,
  FIRE_RESISTANT,
  MELEE_WEAPON,
  AMMUNITION,
} from "../../core/flags.ts";
import {
  BLACK,
  BLUE,
  Color,
  LIGHTBLUE,
  NONE,
  PALEVIOLETRED,
  WHITE,
  colorByName,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import { BaseSerializer } from "../../core/serializer.ts";
import { State, stateFromString } from "../../core/state.ts";
import { Targeting, getDistance } from "./targeting.ts";
import { AbstractAgent } from "./creatures.ts";
import { Animated } from "../../core/animated.ts";
import { Cell } from "../../core/cell.ts";
import { GameEvent } from "../../core/game-event.ts";
import { Agent } from "../../core/agent.ts";
import { Direction } from "../../core/direction.ts";
import { Item } from "../../core/item.ts";
import { Board } from "../../core/board.ts";

const CTBH = {
  ARCHER: 30,
  COMMONER: 50,
  NOBLE: 20,
  RIFLEMAN: 30,
  WIZARD: 40,
};

const DAMAGE = {
  ARCHER: 20,
  COMMONER: 5,
  NOBLE: 20,
  RIFLEMAN: 20,
  WIZARD: 15,
};

/**
 * Base class for human/NPC agents. NPCs can be friendly (off) or hostile (on).
 * In friendly mode they wander; in hostile mode they attack.
 * Quest NPCs can assign and complete quests by firing color events.
 */
export class NPC extends AbstractAgent implements Animated {
  static #ENCOUNTER_TARGETING = new Targeting().attackPlayer(10);
  state: State;
  message: string;
  questColor: Color;
  doneColor: Color;
  inQuestMsg: string;
  flag: number;
  damage: number;

  constructor(
    name: string,
    state: State,
    color: Color,
    flags: number,
    message: string,
    questColor: Color,
    doneColor: Color,
    inQuestMsg: string,
    flag: number,
    chanceToHit: number,
    damage: number,
    glyph: string,
  ) {
    const sym = state.isOn()
      ? Sym.of(glyph, PALEVIOLETRED)
      : questColor != null && questColor !== NONE && doneColor == null
        ? Sym.of(glyph, LIGHTBLUE, null, BLUE, null)
        : Sym.of(glyph, WHITE, null, BLACK, null);
    super(name, flags, color, chanceToHit, sym);
    this.state = state;
    this.message = message;
    this.questColor = questColor ?? null;
    this.doneColor = doneColor ?? null;
    this.inQuestMsg = inQuestMsg ?? null;
    this.flag = flag ?? null;
    this.damage = damage;
  }

  randomSeed(): boolean {
    return true;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (this.state.isOff()) {
      const playerCell = event.board.getCurrentCell();
      if (
        this.#hasColor(this.questColor) &&
        getDistance(cell, playerCell) < 3 &&
        Math.random() * 100 < 70
      ) {
        game.agentMove(cell, this, NPC.#ENCOUNTER_TARGETING);
      } else {
        this.friendlyTurn(event, cell, frame);
      }
    } else {
      this.hostileTurn(event, cell, frame);
    }
  }

  protected hostileTurn(event: GameEvent, cell: Cell, frame: number) { }
  protected friendlyTurn(event: GameEvent, cell: Cell, frame: number) { }

  changeHealth(delta: number): number {
    // Quest providers are indestructible
    return this.#isQuestProvider() ? 1 : super.changeHealth(delta);
  }

  onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
    if (this.state.isOn()) {
      agent.changeHealth(this.damage);
    }
  }

  onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
    super.onHitBy(event, agentLoc, agent, dir);
    if (this.state.isOff()) {
      const selected = event.player.bag.getSelected();
      if (selected.is(MELEE_WEAPON) && !this.#isQuestProvider()) {
        this.#alert(event.board);
      } else {
        const cell = agentLoc.getAdjacentCell(dir);
        if (cell) {
          this.talk(event, cell);
        }
      }
    }
  }

  onHitByItem(event: GameEvent, itemLoc: Cell, item: Item, dir: Direction) {
    super.onHitByItem(event, itemLoc, item, dir);
    if (this.state.isOff() && item.is(AMMUNITION)) {
      this.#alert(event.board);
    }
  }

  #alert(board: Board) {
    board.visit((cell) => {
      const agent = cell.agent;
      if (
        agent instanceof NPC &&
        !agent.#isQuestProvider() &&
        agent.state.isOff()
      ) {
        let key = Registry.serialize(agent);
        key = key.replace(/\boff\b/g, "on");
        const hostile = Registry.agent(key);
        if (hostile) cell.setAgent(hostile);
      }
      return false;
    });
  }

  #isQuestProvider() {
    return this.#hasColor(this.questColor) || this.#hasColor(this.doneColor);
  }

  #hasColor(color: Color) {
    return color != null && color !== NONE;
  }

  talk(event: GameEvent, cell: Cell) {
    const board = event.board;
    const player = event.player;
    if (this.#hasColor(this.questColor)) {
      board.fireColorEvent(event, this.questColor, cell);
      this.#turnOffColor(cell, 4);
    } else if (
      this.#hasColor(this.doneColor) &&
      player.matchesFlagOrItem(this.flag)
    ) {
      board.fireColorEvent(event, this.doneColor, cell);
      this.#turnOffColor(cell, 5);
    } else if (this.#hasColor(this.doneColor)) {
      events.fireModalMessage(this.inQuestMsg);
    } else if (this.message) {
      events.fireModalMessage(this.message);
    }
  }

  #turnOffColor(cell: Cell, index: number) {
    const agent = cell.agent;
    if (!agent) return;
    const key = Registry.serialize(agent);
    const parts = key.split("|");
    parts[index] = "None";
    const next = Registry.agent(parts.join("|"));
    if (next) cell.setAgent(next);
  }
}

// TODO: Obviously any is pretty bad here, but it's complex to fix
function makeNpc(args: string[], Cls: any) {
  if (args.length >= 7) {
    return new Cls(
      stateFromString(args[0]),
      colorByName(args[1]) ?? NONE,
      args[2],
      colorByName(args[3]) ?? NONE,
      colorByName(args[4]) ?? NONE,
      args[5],
      args[6],
    );
  }
  return new Cls(
    stateFromString(args[0]),
    colorByName(args[1]) ?? NONE,
    args[2],
  );
}

function makeNpcSerializer(typeId: string, Cls: any, tag = "People") {
  return new (class extends BaseSerializer {
    create(args: string[]) {
      return makeNpc(args, Cls);
    }
    store(n: NPC) {
      const q = n.questColor,
        d = n.doneColor,
        iq = n.inQuestMsg,
        f = n.flag;
      if (q != null && d != null && iq != null && f != null) {
        return `${typeId}|${n.state}|${n.color.name}|${n.message}|${q.name}|${d.name}|${iq}|${f}`;
      }
      return `${typeId}|${n.state}|${n.color.name}|${n.message}`;
    }
    example() {
      return makeNpc(["off", "White", "Hello"], Cls);
    }
    template() {
      return `${typeId}|{state}|{color}|{message}|{questColor?}|{doneColor?}|{inQuestMsg?}|{flag?}`;
    }
    tag() {
      return tag;
    }
  })();
}

const WANDER_TARGETING = new Targeting().moveRandomly();

export class Commoner extends NPC {
  static #FLEE_TARGETING = new Targeting().fleePlayer(10).moveRandomly();

  constructor(
    state: State,
    color: Color,
    message: string,
    questColor: Color,
    doneColor: Color,
    inQuestMsg: string,
    flag: number,
    name = "Commoner",
    glyph = "A",
  ) {
    super(
      name,
      state,
      color,
      ORGANIC,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      CTBH.COMMONER,
      DAMAGE.COMMONER,
      glyph,
    );
  }
  friendlyTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 10 === 0) {
      game.agentMove(cell, this, WANDER_TARGETING);
    }
  }
  hostileTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 === 0) {
      game.agentMove(cell, this, Commoner.#FLEE_TARGETING);
    }
  }
  static SERIALIZER = makeNpcSerializer("Commoner", Commoner);
}

export class Noble extends NPC {
  static #ATTACK_TARGETING = new Targeting()
    .moveRandomly()
    .attackPlayer(15)
    .trackPlayer();

  constructor(
    state: State,
    color: Color,
    message: string,
    questColor: Color,
    doneColor: Color,
    inQuestMsg: string,
    flag: number,
    name = "Noble",
    glyph = "Ä",
  ) {
    super(
      name,
      state,
      color,
      ORGANIC,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      CTBH.NOBLE,
      DAMAGE.NOBLE,
      glyph,
    );
  }
  friendlyTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 10 === 0) {
      game.agentMove(cell, this, WANDER_TARGETING);
    }
  }
  hostileTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 === 0) {
      game.agentMove(cell, this, Noble.#ATTACK_TARGETING);
    }
  }
  static SERIALIZER = makeNpcSerializer("Noble", Noble);
}

export class Archer extends NPC {
  static #ATTACK_TARGETING = new Targeting()
    .moveRandomly()
    .attackPlayer(10)
    .trackPlayer()
    .keepDistance(4);
  static #FIRE_TARGETING = new Targeting().attackPlayer(10);

  constructor(
    state: State,
    color: Color,
    message: string,
    questColor: Color,
    doneColor: Color,
    inQuestMsg: string,
    flag: number,
    name = "Archer",
    glyph = "Á",
  ) {
    super(
      name,
      state,
      color,
      ORGANIC,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      CTBH.ARCHER,
      DAMAGE.ARCHER,
      glyph,
    );
  }
  friendlyTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 10 === 0) {
      game.agentMove(cell, this, WANDER_TARGETING);
    }
  }
  hostileTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 === 0) {
      game.agentMove(cell, this, Archer.#ATTACK_TARGETING);
    }
    if (frame % 5 === 0) {
      game.agentShoot(cell, this, Registry.item("Arrow"), Archer.#FIRE_TARGETING);
    }
  }
  static SERIALIZER = makeNpcSerializer("Archer", Archer);
}

export class Rifleman extends NPC {
  static #ATTACK_TARGETING = new Targeting()
    .moveRandomly()
    .attackPlayer(10)
    .trackPlayer()
    .keepDistance(4);
  static #FIRE_TARGETING = new Targeting().attackPlayer(10);

  constructor(
    state: State,
    color: Color,
    message: string,
    questColor: Color,
    doneColor: Color,
    inQuestMsg: string,
    flag: number,
    name = "Rifleman",
    glyph = "Â",
  ) {
    super(
      name,
      state,
      color,
      ORGANIC,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      CTBH.RIFLEMAN,
      DAMAGE.RIFLEMAN,
      glyph,
    );
  }
  friendlyTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 10 === 0) {
      game.agentMove(cell, this, WANDER_TARGETING);
    }
  }
  hostileTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 === 0) {
      game.agentMove(cell, this, Rifleman.#ATTACK_TARGETING);
    }
    if (frame % 5 === 0) {
      game.agentShoot(cell, this, Registry.item("Bullet"), Rifleman.#FIRE_TARGETING);
    }
  }
  static SERIALIZER = makeNpcSerializer("Rifleman", Rifleman);
}

export class Wizard extends NPC {
  static #ATTACK_TARGETING = new Targeting()
    .moveRandomly()
    .attackPlayer(10)
    .trackPlayer()
    .keepDistance(4);
  static #FIRE_TARGETING = new Targeting().attackPlayer(10);

  constructor(
    state: State,
    color: Color,
    message: string,
    questColor: Color,
    doneColor: Color,
    inQuestMsg: string,
    flag: number,
    name = "Wizard",
    glyph = "Ã",
  ) {
    super(
      name,
      state,
      color,
      ORGANIC | FIRE_RESISTANT,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      CTBH.WIZARD,
      DAMAGE.WIZARD,
      glyph,
    );
  }
  friendlyTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 10 === 0) {
      game.agentMove(cell, this, WANDER_TARGETING);
    }
  }
  hostileTurn(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 5 === 0) {
      const ammo = Math.random() * 3 < 1 ? Registry.item("Parabullet") : Registry.item("Fireball");
      game.agentShoot(cell, this, ammo, Wizard.#FIRE_TARGETING);
    } else if (frame % 7 === 0) {
      game.agentMove(cell, this, Wizard.#ATTACK_TARGETING);
    }
  }
  static SERIALIZER = makeNpcSerializer("Wizard", Wizard);
}

export class MallocCommoner extends Commoner {
  constructor(state: State, color: Color, message: string, questColor: Color, doneColor: Color, inQuestMsg: string, flag: number) {
    super(
      state,
      color,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      "Malloc Grunt",
      "O",
    );
  }
  static SERIALIZER = makeNpcSerializer(
    "MallocCommoner",
    MallocCommoner,
    "Mallocs",
  );
}

export class MallocNoble extends Noble {
  constructor(state: State, color: Color, message: string, questColor: Color, doneColor: Color, inQuestMsg: string, flag: number) {
    super(
      state,
      color,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      "Malloc Lord",
      "Ö",
    );
  }
  static SERIALIZER = makeNpcSerializer("MallocNoble", MallocNoble, "Mallocs");
}

export class MallocArcher extends Archer {
  constructor(state: State, color: Color, message: string, questColor: Color, doneColor: Color, inQuestMsg: string, flag: number) {
    super(
      state,
      color,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      "Malloc Archer",
      "Ó",
    );
  }
  static SERIALIZER = makeNpcSerializer(
    "MallocArcher",
    MallocArcher,
    "Mallocs",
  );
}

export class MallocRifleman extends Rifleman {
  constructor(state: State, color: Color, message: string, questColor: Color, doneColor: Color, inQuestMsg: string, flag: number) {
    super(
      state,
      color,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      "Malloc Carbiner",
      "Ô",
    );
  }
  static SERIALIZER = makeNpcSerializer(
    "MallocRifleman",
    MallocRifleman,
    "Mallocs",
  );
}

export class MallocWizard extends Wizard {
  constructor(state: State, color: Color, message: string, questColor: Color, doneColor: Color, inQuestMsg: string, flag: number) {
    super(
      state,
      color,
      message,
      questColor,
      doneColor,
      inQuestMsg,
      flag,
      "Malloc Shaman",
      "Õ",
    );
  }
  static SERIALIZER = makeNpcSerializer(
    "MallocWizard",
    MallocWizard,
    "Mallocs",
  );
}

export function registerNPCs() {
  Registry.register("Commoner", Commoner.SERIALIZER);
  Registry.register("Noble", Noble.SERIALIZER);
  Registry.register("Archer", Archer.SERIALIZER);
  Registry.register("Rifleman", Rifleman.SERIALIZER);
  Registry.register("Wizard", Wizard.SERIALIZER);
  Registry.register("MallocCommoner", MallocCommoner.SERIALIZER);
  Registry.register("MallocNoble", MallocNoble.SERIALIZER);
  Registry.register("MallocArcher", MallocArcher.SERIALIZER);
  Registry.register("MallocRifleman", MallocRifleman.SERIALIZER);
  Registry.register("MallocWizard", MallocWizard.SERIALIZER);
}