pieces/terrain/water.ts

import { BlueRing } from "../../pieces/items/items.ts";
import { Terrain } from "../../core/terrain.ts";
import { Registry } from "../../core/registry.ts";
import { TypeOnlySerializer } from "../../core/serializer.ts";
import { events } from "../../core/event-bus.ts";
import {
  TRAVERSABLE,
  PENETRABLE,
  ETHEREAL,
  AQUATIC,
  LAVITIC,
  WATER_RESISTANT,
} from "../../core/flags.ts";
import {
  DARKKHAKI,
  OCEAN,
  SIENNA,
  SURF,
  MUD,
  BUSHES,
  LAVA,
  LIGHTPINK,
  WAVES,
  NONE,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import { Item } from "../../core/item.ts";
import { Cell } from "../../core/cell.ts";
import { GameEvent } from "../../core/game-event.ts";
import { Direction } from "../../core/direction.ts";
import { Player } from "../../core/player.ts";
import { Agent } from "../../core/agent.ts";
import { Animated } from "../../core/animated.ts";

/**
 * Deep water — only AQUATIC agents (or player with BlueRing/WATER_RESISTANT) can enter.
 */
class Water extends Terrain {
  constructor() {
    super("Water", AQUATIC, NONE, Sym.of("\u2003", NONE, OCEAN));
  }
  onDrop(event: GameEvent, cell: Cell, item: Item) {
    event.cancel("It falls into the water.", cell);
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Water");
    }
    create() {
      return new Water();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Ocean — deep water. Unlike Water, entry is always blocked for the
 * player, even with the blue ring or WATER_RESISTANT: the cold, dark,
 * and pressure of the deep ocean are too much regardless.
 */
class Ocean extends Terrain {
  constructor() {
    super("Ocean", AQUATIC, NONE, Sym.of("\u2003", NONE, OCEAN));
  }
  onEnter(event: GameEvent, player: Player, cell: Cell, _dir: Direction) {
    event.cancel();
    const item = player.bag.getSelected();
    if (item instanceof BlueRing) {
      events.fireMessage(
        "You can breathe underwater with the ring on, but the cold, the dark and the pressure are too much to continue", cell
      );
    } else if (player.is(WATER_RESISTANT)) {
      events.fireMessage(
        "The cold, the dark, and the pressure are too much for you.", cell
      );
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Ocean");
    }
    create() {
      return new Ocean();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** ShallowWater — traversable by anyone; just wet */
class ShallowWater extends Terrain {
  constructor() {
    super(
      "Shallow Water",
      TRAVERSABLE | PENETRABLE,
      NONE,
      Sym.of("\u2003", NONE, SURF),
    );
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("ShallowWater");
    }
    create() {
      return new ShallowWater();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** Surf — identical appearance to ShallowWater (beach wave edge) */
class Surf extends Terrain {
  constructor() {
    super("Surf", TRAVERSABLE | PENETRABLE, NONE, Sym.of("\u2003", NONE, SURF));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Surf");
    }
    create() {
      return new Surf();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Mud — ETHEREAL (blocks items), traversable but sticky.
 * On exit there's a 70% chance you are stuck (move is cancelled).
 */
class Mud extends Terrain {
  constructor() {
    super(
      "Mud",
      TRAVERSABLE | PENETRABLE | ETHEREAL,
      NONE,
      Sym.of("\u2003", NONE, MUD),
    );
  }
  onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (event.board.getCurrentCell() === cell || cell.containsPlayer()) {
      if (Math.random() < 0.7) {
        events.fireMessage("You are stuck in the mud!", cell);
        event.cancel();
      }
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Mud");
    }
    create() {
      return new Mud();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * ShallowSwamp — ETHEREAL, traversable, sticky, but easier to escape than
 * deep Swamp: only 30% stuck on exit (vs. Swamp's 70%).
 */
class ShallowSwamp extends Terrain {
  constructor() {
    super(
      "Shallow Swamp",
      TRAVERSABLE | PENETRABLE | ETHEREAL,
      NONE,
      Sym.of("…", BUSHES, SURF),
    );
  }
  onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (Math.random() < 0.3) {
      events.fireMessage("You are stuck in the swamp!", cell);
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("ShallowSwamp");
    }
    create() {
      return new ShallowSwamp();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Swamp — ETHEREAL, traversable, deep mucky water.
 * Same sticky mechanic as ShallowSwamp.
 */
class Swamp extends Terrain {
  constructor() {
    super(
      "Swamp",
      TRAVERSABLE | PENETRABLE | ETHEREAL,
      NONE,
      Sym.of("…", BUSHES, OCEAN),
    );
  }
  onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    if (Math.random() < 0.7) {
      events.fireMessage("You are stuck in the swamp!", cell);
      event.cancel();
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Swamp");
    }
    create() {
      return new Swamp();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/** Lava — LAVITIC: only lava-adapted agents can enter */
class Lava extends Terrain {
  constructor() {
    super("Lava", LAVITIC, NONE, Sym.of("\u2003", NONE, LAVA));
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Lava");
    }
    create() {
      return new Lava();
    }
    tag() {
      return "Terrain";
    }
  })();
}

/**
 * BubblingLava — animated lava that cycles through 8 symbols, then moves
 * to an adjacent Lava cell (mirroring the Java implementation).
 *
 * Java: cycles every 4 ticks, full period = 36 (8 symbols + 1 move tick).
 * At t=8 (frame%36===32) it swaps cells with an adjacent lava cell twice
 * to "flow" in a random direction.
 */
class BubblingLava extends Terrain implements Animated {
  static SYMBOLS = [
    Sym.of(".", LIGHTPINK, LAVA),
    Sym.of(":", LIGHTPINK, LAVA),
    Sym.of("'", LIGHTPINK, LAVA),
    Sym.of("ν", LIGHTPINK, LAVA),
    Sym.of("∴", LIGHTPINK, LAVA),
    Sym.of("⋅", LIGHTPINK, LAVA),
    Sym.of(".", LIGHTPINK, LAVA),
    Sym.of("\u2003", LIGHTPINK, LAVA),
  ];
  constructor() {
    super("Bubbling Lava", LAVITIC, NONE, BubblingLava.SYMBOLS[0]);
  }
  randomSeed(): boolean {
    return true;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 4 !== 0) return;
    const t = ((frame % 36) / 4) | 0; // 0..8
    if (t < 8) {
      cell.animTerrainSymbol = BubblingLava.SYMBOLS[t];
      cell.board.notifyCellChange(cell);
    } else {
      // t === 8: flow — find a lava cell adjacent to an adjacent lava cell (two hops)
      const lava = Registry.terrain("Lava");
      let dest = this.#findAdjacentLava(cell);
      if (dest) {
        dest = this.#findAdjacentLava(dest);
        if (dest) {
          cell.animTerrainSymbol = null;
          cell.setTerrain(lava);
          dest.setTerrain(this);
        }
      }
    }
  }
  /** @private */
  #findAdjacentLava(cell: Cell): Cell | null {
    const dirs = [
      [1, 0],
      [-1, 0],
      [0, 1],
      [0, -1],
    ];
    const candidates = [];
    for (const [dx, dy] of dirs) {
      const adj = cell.board.getCellAt(cell.x + dx, cell.y + dy);
      if (adj && adj.terrain && adj.terrain.name === "Lava")
        candidates.push(adj);
    }
    if (candidates.length === 0) return null;
    return candidates[Math.floor(Math.random() * candidates.length)];
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("BubblingLava");
    }
    create() {
      return new BubblingLava();
    }
    tag() {
      return "Terrain";
    }
  })();
}

/**
 * Waterfall — impassable animated waterfall. Unlike Water/Ocean this is not
 * AQUATIC: flags are 0, so it blocks everyone, even aquatic agents and
 * water-resistant players (a waterfall is too turbulent for anyone to cross).
 *
 * Java: 5 symbols, cycles every 3 ticks, full period = 15 (same cadence as
 * Fountain). Uses randomSeed so multiple waterfalls animate independently.
 */
class Waterfall extends Terrain implements Animated {
  static SYMBOLS = [
    Sym.of("¨", SURF, OCEAN),
    Sym.of("≈", SURF, OCEAN),
    Sym.of("…", SURF, OCEAN)
  ];
  constructor() {
    super("Waterfall", 0, NONE, Waterfall.SYMBOLS[0]);
  }
  randomSeed(): boolean {
    return true;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 3 === 0) {
      cell.animTerrainSymbol = Waterfall.SYMBOLS[(frame % 15) / 3];
      cell.board.notifyCellChange(cell);
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Waterfall");
    }
    create() {
      return new Waterfall();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Raft — a water-traversal terrain. When an agent moves off a raft onto
 * Water or Ocean, the raft moves with them (swapping cells). Moving onto
 * Waterfall or shallow water is blocked with a message.
 */
class Raft extends Terrain {
  constructor() {
    super(
      "Raft",
      TRAVERSABLE | PENETRABLE,
      NONE,
      Sym.of("\u2263", SIENNA, DARKKHAKI),
    );
  }
  #moveRaft(event: GameEvent, cell: Cell, dir: Direction) {
    const next = cell.getAdjacentCell(dir);
    if (!next) return;
    const t = next.terrain;
    if (t instanceof Waterfall) {
      event.cancel("The water is too turbulent for a raft", cell);
    } else if (t instanceof ShallowWater || t instanceof Surf) {
      event.cancel("It's too shallow for the raft here.", cell);
    } else if (t && t.is(AQUATIC)) {
      next.setTerrain(this);
      cell.setTerrain(t);
    }
  }
  onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
    this.#moveRaft(event, cell, dir);
  }
  onAgentExit(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
    this.#moveRaft(event, cell, dir);
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Raft");
    }
    create() {
      return new Raft();
    }
    tag() {
      return "Outside Terrain";
    }
  })();
}

/**
 * Fountain — impassable animated fountain.
 *
 * Java: 5 symbols, cycles every 3 ticks, full period = 15.
 * Uses randomSeed so multiple fountains animate independently.
 */
class Fountain extends Terrain implements Animated {
  static SYMBOLS = [
    Sym.of("·", SURF, OCEAN),
    Sym.of("•", SURF, OCEAN),
    Sym.of("o", SURF, OCEAN),
    Sym.of("O", SURF, OCEAN),
    Sym.of("©", SURF, OCEAN),
  ];
  constructor() {
    super("Fountain", 0, NONE, Fountain.SYMBOLS[4]);
  }
  randomSeed(): boolean {
    return true;
  }
  onFrame(event: GameEvent, cell: Cell, frame: number) {
    if (frame % 3 === 0) {
      cell.animTerrainSymbol = Fountain.SYMBOLS[(frame % 15) / 3];
      cell.board.notifyCellChange(cell);
    }
  }
  static SERIALIZER = new (class extends TypeOnlySerializer {
    constructor() {
      super("Fountain");
    }
    create() {
      return new Fountain();
    }
    tag() {
      return "Room Features";
    }
  })();
}

export function registerWaterTerrain() {
  Registry.register("Water", Water.SERIALIZER);
  Registry.register("Ocean", Ocean.SERIALIZER);
  Registry.register("ShallowWater", ShallowWater.SERIALIZER);
  Registry.register("Surf", Surf.SERIALIZER);
  Registry.register("Mud", Mud.SERIALIZER);
  Registry.register("ShallowSwamp", ShallowSwamp.SERIALIZER);
  Registry.register("Swamp", Swamp.SERIALIZER);
  Registry.register("Lava", Lava.SERIALIZER);
  Registry.register("BubblingLava", BubblingLava.SERIALIZER);
  Registry.register("Waterfall", Waterfall.SERIALIZER);
  Registry.register("Raft", Raft.SERIALIZER);
  Registry.register("Fountain", Fountain.SERIALIZER);
}