import { Agent } from "../../core/agent.js";
import { AgentProxy } from "../../core/agent-proxy.js";
import { Registry } from "../../core/registry.js";
import { game } from "../../core/game.js";
import { events } from "../../core/events.js";
import { EMPTY_HANDED } from "../../core/player.js";
import { Targeting, findPathInDirection } from "./targeting.js";
import { TerrainUtils } from "../../core/terrain-utils.js";
import {
ORGANIC,
PENETRABLE,
ETHEREAL,
FLIER,
CARNIVORE,
PUSHABLE,
FIRE_RESISTANT,
LAVITIC,
AQUATIC,
PLAYER,
WEAK,
NOT_EDITABLE,
PARALYZED,
THEFT_RESISTANT,
AMMUNITION,
} from "../../core/flags.js";
import {
NONE,
BROWN,
DARKGOLDENROD,
RED,
ORANGE,
DARKORANGE,
ORCHID,
DARKORCHID,
SILVER,
DARKSLATEBLUE,
POWDERBLUE,
MIDNIGHTBLUE,
GOLD,
SLATEBLUE,
FIREBRICK,
OLIVEDRAB,
DARKOLIVEGREEN,
DARKGREEN,
SEAGREEN,
MEDIUMSEAGREEN,
DARKSEAGREEN,
BLUE,
DARKBLUE,
WHITE,
BLACK,
SADDLEBROWN,
LIGHTSTEELBLUE,
VIOLET,
MAROON,
LESSNEARBLACK,
BUILDING_WALL,
BUILDING_FLOOR,
DARKVIOLET,
CYAN,
SALMON as SALMONCOLOR,
colorByName,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { stateFromString } from "../../core/state.js";
import {
directionByName,
ADJ_DIRECTIONS,
NORTH,
SOUTH,
EAST,
WEST,
} from "../../core/direction.js";
// ── Serializer helpers ───────────────────────────────────────────────────────
function makeColoredSerializer(typeId, Cls) {
return new (class extends BaseSerializer {
create([color]) {
return new Cls(colorByName(color) ?? NONE);
}
store(a) {
return `${typeId}|${a.color.name}`;
}
example() {
return new Cls(NONE);
}
template(_id) {
return `${typeId}|{color}`;
}
tag() {
return "Agents";
}
})();
}
// ── Combat stats (mirrors CombatStats.java) ───────────────────────────────────
const INDESTRUCTIBLE = -500;
const CTBH = {
ASCIIROTH: -20,
CEPHALID: 0,
CORVID: 25,
FARTHAPOD: 15,
HOOLOOVOO: -20,
KILLER_BEE: 5,
LAVA_WORM: 25,
LIGHTNING_LIZARD: 25,
OPTILISK: 25,
RHINDLE: 15,
SLEESTAK: 15,
TETRITE: 25,
THERMADON: 25,
TRIFFID: 15,
};
const DAMAGE = {
ASCIIROTH: 50,
BOULDER: 30,
CORVID: 5,
FARTHAPOD: 5,
GREAT_OLD_ONE: 130,
HOOLOOVOO: 20,
KILLER_BEE: 10,
LAVA_WORM: 20,
LIGHTNING_LIZARD: 3,
OPTILISK: 40,
PUSHER: 3,
RHINDLE: 30,
SLEESTAK: 30,
TETRITE: 10,
THERMADON: 20,
TRIFFID: 20,
};
// ── AbstractAgent base ────────────────────────────────────────────────────────
/**
* Base for all game agents. Implements the health-as-hit-chance mechanic:
* `chanceToHit` is a baseline %; weapons add their damage value. If the
* resulting roll succeeds the agent is destroyed (returns 0).
*
* INDESTRUCTIBLE agents have chanceToHit = -500 (virtually impossible to kill).
*/
export class AbstractAgent extends Agent {
constructor(name, flags, color, chanceToHit, symbol) {
super(name, flags, color, symbol);
this._chanceToHit = chanceToHit;
}
changeHealth(delta) {
const test = this._chanceToHit + delta;
return Math.random() * 100 <= test ? 0 : 1;
}
onDie(event, cell) {
// Fire this agent's color event on death (mirrors AbstractAgent.onDie)
if (this.color && this.color !== NONE) {
event.board?.fireColorEvent(event, this.color, cell);
}
}
onHitBy(event, agentLoc, agent, dir) {
if (agent.is(PLAYER) && this.is(PUSHABLE)) {
if (agent.is(WEAK)) {
event.cancel("You're too weak to push anything");
} else {
// agentLoc = attacker's cell (Java convention); our cell = agentLoc + dir
const myCell =
agentLoc.board?.getAdjacentCell(agentLoc.x, agentLoc.y, dir) ??
agentLoc;
game.agentMoveInDirection(event, myCell, this, dir);
}
} else if (
agent instanceof RollingBoulder &&
!(this instanceof ImmobileAgent)
) {
const myCell =
agentLoc.board?.getAdjacentCell(agentLoc.x, agentLoc.y, dir) ??
agentLoc;
game.agentMoveInDirection(event, myCell, this, dir);
if (event.isCancelled) {
event.board?.fireColorEvent?.(event, this.color, agentLoc);
}
} else {
event.cancel();
}
}
}
// ── ImmobileAgent base ────────────────────────────────────────────────────────
/** An agent that cannot be moved or destroyed. */
export class ImmobileAgent extends AbstractAgent {
constructor(name, flags, symbol) {
super(name, flags, null, INDESTRUCTIBLE, symbol);
}
changeHealth(_value) {
return 100;
}
}
// ── Static / puzzle agents ────────────────────────────────────────────────────
export class AbstractBoulder extends AbstractAgent {
constructor(name, flags, color, chanceToHit, symbol) {
super(name, flags, color, chanceToHit, symbol);
}
}
export class Boulder extends AbstractBoulder {
constructor() {
super(
"Boulder",
PUSHABLE,
NONE,
INDESTRUCTIBLE,
Symbol.of("O", DARKGOLDENROD),
);
}
changeHealth(_v) {
return 100;
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Boulder");
}
create(_args) {
return new Boulder();
}
tag() {
return "Room Features";
}
})();
}
export class RollingBoulder extends AbstractBoulder {
constructor(direction, color, state) {
super("Rolling Boulder", 0, color, INDESTRUCTIBLE, Symbol.of("O", BROWN));
this._direction = direction;
this._state = state;
}
onHit(_event, _boulderCell, playerCell, player) {
// Deal rolling damage; if it kills the player stop here.
if (player.changeHealth(DAMAGE.BOULDER) === 0) return;
// Push the player one step further in the rolling direction if possible.
const beyond = playerCell.getAdjacentCell(this._direction);
if (beyond && beyond.canEnter(playerCell, player, this._direction)) {
game._movePlayer(this._direction);
} else {
// Player is trapped between boulder and obstacle — crush damage.
player.changeHealth(500);
}
}
onFrame(_event, cell, frame) {
if (this._state.isOn() && frame % 5 === 0) {
const event = game.createEvent();
game.agentMoveInDirection(event, cell, this, this._direction);
}
}
onColorEvent(_event, color, cell) {
if (color === this.color && this._state.isOff()) {
cell.removeAgent(this);
const next = Registry.get(
`RollingBoulder|${this._direction.name}|${this.color.name}|on`,
);
cell.setAgent(next);
}
}
changeHealth(_v) {
return 100;
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir, color, state]) {
return new RollingBoulder(
directionByName(dir),
colorByName(color) ?? NONE,
stateFromString(state),
);
}
store(b) {
return `RollingBoulder|${b._direction.name}|${b.color.name}|${b._state}`;
}
example() {
return new RollingBoulder(EAST, NONE, stateFromString("off"));
}
template(_id) {
return "RollingBoulder|{direction}|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
export class Pusher extends AbstractAgent {
constructor(direction, color, state) {
const glyph =
direction === NORTH
? "\u25B2"
: direction === SOUTH
? "\u25BC"
: direction === EAST
? "\u25BA"
: direction === WEST
? "\u25C4"
: "?";
super(
"Pusher",
0,
color,
INDESTRUCTIBLE,
Symbol.of(glyph, LIGHTSTEELBLUE, null, MIDNIGHTBLUE, null),
);
this.isPusher = true;
this._direction = direction;
this._state = state;
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.PUSHER);
}
onFrame(_event, cell, frame) {
if (this._state.isOn() && frame % 6 === 0) {
const event = game.createEvent();
game.agentMoveInDirection(event, cell, this, this._direction);
}
}
onColorEvent(_event, color, cell) {
if (color === this.color && this._state.isOff()) {
cell.removeAgent(this);
const next = Registry.get(
`Pusher|${this._direction.name}|${this.color.name}|on`,
);
cell.setAgent(next);
}
}
changeHealth(_v) {
return 100;
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir, color, state]) {
return new Pusher(
directionByName(dir),
colorByName(color) ?? NONE,
stateFromString(state),
);
}
store(p) {
return `Pusher|${p._direction.name}|${p.color.name}|${p._state}`;
}
example() {
return new Pusher(NORTH, NONE, stateFromString("off"));
}
template(_id) {
return "Pusher|{direction}|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
export class Slider extends AbstractAgent {
constructor(direction) {
const glyph = direction === NORTH ? "↕" : "↔";
super(
"Slider",
PUSHABLE,
NONE,
INDESTRUCTIBLE,
Symbol.of(glyph, VIOLET, null, MAROON, null),
);
this.isSlider = true;
this._direction = direction;
}
changeHealth(_v) {
return 100;
}
onHitBy(event, agentLoc, agent, dir) {
if (
(this._direction.isNorthSouth() && dir.isNorthSouth()) ||
(this._direction.isEastWest() && dir.isEastWest())
) {
// agentLoc = attacker's cell (Java convention); our cell = agentLoc + dir
const myCell =
agentLoc.board?.getAdjacentCell(agentLoc.x, agentLoc.y, dir) ??
agentLoc;
game.agentMoveInDirection(event, myCell, this, dir);
} else {
event.cancel();
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir]) {
return new Slider(directionByName(dir));
}
store(s) {
return `Slider|${s._direction.name}`;
}
example() {
return new Slider(NORTH);
}
template(_id) {
return "Slider|{direction}";
}
tag() {
return "Room Features";
}
})();
}
export class Campfire extends AbstractAgent {
constructor() {
super(
"Campfire",
PENETRABLE | ETHEREAL,
NONE,
INDESTRUCTIBLE,
Symbol.of("ω", RED),
);
}
changeHealth(_v) {
return 100;
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Campfire");
}
create(_args) {
return new Campfire();
}
tag() {
return "Room Features";
}
})();
}
export class Pillar extends ImmobileAgent {
constructor() {
super(
"Pillar",
0,
Symbol.of("¶", LESSNEARBLACK, null, BUILDING_WALL, BUILDING_FLOOR),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Pillar");
}
create(_args) {
return new Pillar();
}
tag() {
return "Room Features";
}
})();
}
// ── Animated creatures ────────────────────────────────────────────────────────
export class Asciiroth extends AbstractAgent {
constructor(color) {
super("Asciiroth", 0, color, CTBH.ASCIIROTH, Symbol.of("א", CYAN));
this._movTargeting = new Targeting()
.attackPlayer(25)
.keepDistance(6)
.moveRandomly();
this._fireTargeting = new Targeting().attackPlayer(20);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.ASCIIROTH);
}
onFrame(_event, cell, frame) {
if (frame % 4 === 0) {
game.agentShoot(
cell,
this,
Registry.get("Fireball"),
this._fireTargeting,
);
} else if (frame % 7 === 0) {
game.agentMove(cell, this, this._movTargeting);
}
}
static SERIALIZER = makeColoredSerializer("Asciiroth", Asciiroth);
}
export class Cephalid extends AbstractAgent {
constructor(color) {
super(
"Cephalid",
ORGANIC,
color,
CTBH.CEPHALID,
Symbol.of("€", ORCHID, null, DARKORCHID, null),
);
this._movTargeting = new Targeting()
.attackPlayer(14)
.moveRandomly()
.trackPlayer();
this._shootTargeting = new Targeting().attackPlayer(14);
}
onFrame(_event, cell, frame) {
if (frame % 5 === 0) game.agentMove(cell, this, this._movTargeting);
else if (frame % 7 === 0) {
if (game.player?.bag?.getSelected()?.name !== "The Mirror Shield") {
game.agentShoot(
cell,
this,
Registry.get("Stoneray"),
this._shootTargeting,
);
}
}
}
static SERIALIZER = makeColoredSerializer("Cephalid", Cephalid);
}
export class Corvid extends AbstractAgent {
static SYMBOLS = [
Symbol.of("∧", POWDERBLUE, null, MIDNIGHTBLUE, null),
Symbol.of("∨", POWDERBLUE, null, MIDNIGHTBLUE, null),
];
static _stealing = new Targeting()
.attackPlayer(12)
.moveRandomly()
.dodgeBullets(60);
static _running = new Targeting().fleePlayer(12).dodgeBullets(60);
constructor(color, item = null) {
super("Corvid", FLIER | ORGANIC, color, CTBH.CORVID, Corvid.SYMBOLS[0]);
this._item = item;
}
onHit(event, attackerLoc, _agentLoc, _agent) {
const player = event.player;
const held = player.bag.getSelected();
if (held !== EMPTY_HANDED) {
if (player.testResistance(THEFT_RESISTANT)) return;
const e = game.createEvent();
held.onDeselect(e, e.board?.getCurrentCell?.() ?? attackerLoc);
if (e.isCancelled) return;
player.bag.remove(held);
events.fireModalMessage(
`The corvid snatches the ${held.name} from your hands!`,
);
attackerLoc.removeAgent(this);
attackerLoc.setAgent(new Corvid(this.color, held));
} else {
event.player.changeHealth(DAMAGE.CORVID);
}
}
onHitBy(event, _agentLoc, _agent, _dir) {
event.cancel();
}
onDie(event, cell) {
if (this._item) cell.addItem(this._item);
super.onDie(event, cell);
}
onFrame(_event, cell, frame) {
this.symbol = Corvid.SYMBOLS[frame % 2];
cell.board._notifyCellChange(cell);
if (frame % 3 === 0) {
game.agentMove(
cell,
this,
this._item ? Corvid._running : Corvid._stealing,
);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([color, itemArg]) {
const item = itemArg
? typeof itemArg === "string"
? Registry.get(itemArg)
: itemArg
: null;
return new Corvid(colorByName(color) ?? NONE, item);
}
store(c) {
return c._item
? `Corvid|${c.color.name}|${this.esc(c._item)}`
: `Corvid|${c.color.name}`;
}
example() {
return new Corvid(NONE);
}
template(_id) {
return "Corvid|{color}|{item?}";
}
tag() {
return "Agents";
}
})();
}
export class Farthapod extends AbstractAgent {
constructor(color) {
super(
"Farthapod",
CARNIVORE | ORGANIC,
color,
CTBH.FARTHAPOD,
Symbol.of("¤", SILVER, null, DARKSLATEBLUE, null),
);
this._targeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(7)
.moveRandomly()
.trackPlayer();
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.FARTHAPOD);
}
onFrame(_event, cell, frame) {
if (frame % 5 === 0) {
game.agentMove(cell, this, this._targeting);
}
}
static SERIALIZER = makeColoredSerializer("Farthapod", Farthapod);
}
export class GreatOldOne extends AbstractAgent {
constructor(color) {
super(
"Great Old One",
0,
color,
INDESTRUCTIBLE,
Symbol.of("ξ", WHITE, null, BLACK, null),
);
this._targeting = new Targeting()
.attackPlayer(12)
.moveRandomly()
.trackPlayer();
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.GREAT_OLD_ONE);
}
onFrame(_event, cell, frame) {
if (frame % 5 === 0) {
game.agentMove(cell, this, this._targeting);
}
}
static SERIALIZER = makeColoredSerializer("GreatOldOne", GreatOldOne);
}
export class Hooloovoo extends AbstractAgent {
constructor() {
super(
"Hooloovoo",
ORGANIC,
null,
CTBH.HOOLOOVOO,
Symbol.of("H", SLATEBLUE, DARKSLATEBLUE),
);
this._targeting = new Targeting().attackPlayer(8).moveRandomly();
}
onHit(event, _attackerLoc, agentLoc, agent) {
const player = event.player;
if (player.bag.size() > 1) {
if (player.testResistance(THEFT_RESISTANT)) {
return;
}
while (player.bag.size() > 1) {
const item = player.bag.last();
const e = game.createEvent();
item.onDeselect(e, e.board?.getCurrentCell() ?? agentLoc);
if (e.isCancelled) {
continue;
}
player.bag.remove(item);
const cell = event.board.findRandomCell();
if (cell) {
cell.addItem(item);
}
}
events.fireModalMessage(
"The Hooloovoo teleports your stuff all over the place",
);
} else {
agent.changeHealth(DAMAGE.HOOLOOVOO);
}
}
onHitByItem(event, itemLoc, item, dir) {
// Ammunition bounces off a Hooloovoo and becomes dangerous to the player
if (item.is(AMMUNITION)) {
game.shoot(event, itemLoc, this, item, dir.reverse);
event.cancel();
}
}
onFrame(_event, cell, frame) {
if (frame % 6 === 0) {
game.agentMove(cell, this, this._targeting);
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Hooloovoo");
}
create(_args) {
return new Hooloovoo();
}
tag() {
return "Agents";
}
})();
}
export class KillerBee extends AbstractAgent {
constructor(color) {
super(
"Killer Bee",
FLIER | ORGANIC,
color,
CTBH.KILLER_BEE,
Symbol.of("a", GOLD),
);
this._targeting = new Targeting().attackPlayer(7).moveRandomly();
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.KILLER_BEE);
}
onHitBy(event, _agentLoc, _agent, _dir) {
event.cancel();
}
onFrame(_event, cell, frame) {
if (frame % 3 === 0) {
game.agentMove(cell, this, this._targeting);
}
}
static SERIALIZER = makeColoredSerializer("KillerBee", KillerBee);
}
export class LavaWorm extends AbstractAgent {
constructor(color) {
super(
"Lava Worm",
LAVITIC | CARNIVORE | FIRE_RESISTANT,
color,
CTBH.LAVA_WORM,
Symbol.of("z", RED),
);
this._targeting = new Targeting()
.attackPlayer(8)
.moveRandomly()
.trackPlayer();
}
canEnter(_direction, _from, to) {
return to.terrain.is(LAVITIC);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.LAVA_WORM);
}
onFrame(_event, cell, frame) {
if (frame % 3 === 0) {
game.agentMove(cell, this, this._targeting);
}
}
static SERIALIZER = makeColoredSerializer("LavaWorm", LavaWorm);
}
export class LightningLizard extends AbstractAgent {
constructor(color) {
super(
"Lightning Lizard",
CARNIVORE | ORGANIC,
color,
CTBH.LIGHTNING_LIZARD,
Symbol.of("£", ORANGE, null, DARKORANGE, null),
);
this._targeting = new Targeting().attackPlayer(12).dodgeBullets(90);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.LIGHTNING_LIZARD);
}
onFrame(_event, cell, _frame) {
game.agentMove(cell, this, this._targeting);
}
static SERIALIZER = makeColoredSerializer("LightningLizard", LightningLizard);
}
export class Optilisk extends AbstractAgent {
constructor(color) {
super(
"Optilisk",
0,
color,
CTBH.OPTILISK,
Symbol.of("e", BLUE, null, DARKBLUE, null),
);
this._movTargeting = new Targeting()
.attackPlayer(14)
.moveRandomly()
.trackPlayer();
this._shootTargeting = new Targeting().attackPlayer(10);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.OPTILISK);
}
onFrame(_event, cell, frame) {
if (frame % 6 === 0) {
if (game.player?.bag?.getSelected()?.name !== "The Mirror Shield") {
game.agentShoot(
cell,
this,
Registry.get("Parabullet"),
this._shootTargeting,
);
}
} else if (frame % 8 === 0) {
game.agentMove(cell, this, this._movTargeting);
}
}
static SERIALIZER = makeColoredSerializer("Optilisk", Optilisk);
}
export class Rhindle extends AbstractAgent {
constructor(color) {
super(
"Rhindle",
CARNIVORE | ORGANIC | FIRE_RESISTANT,
color,
CTBH.RHINDLE,
Symbol.of("&", WHITE, null, BLACK, null),
);
this._targeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(10)
.moveRandomly()
.trackPlayer();
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.RHINDLE);
}
onFrame(_event, cell, frame) {
if (frame % 5 === 0) game.agentMove(cell, this, this._targeting);
}
static SERIALIZER = makeColoredSerializer("Rhindle", Rhindle);
}
export class Sleestak extends AbstractAgent {
constructor(color) {
super(
"Sleestak",
CARNIVORE | ORGANIC,
color,
CTBH.SLEESTAK,
Symbol.of("S", OLIVEDRAB, null, DARKOLIVEGREEN, null),
);
this._movTargeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(12)
.moveRandomly()
.trackPlayer();
this._shootTargeting = new Targeting().attackPlayer(10);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.SLEESTAK);
}
onFrame(_event, cell, frame) {
if (frame % 7 === 0) {
game.agentMove(cell, this, this._movTargeting);
} else if (frame % 6 === 0) {
game.agentShoot(cell, this, Registry.get("Arrow"), this._shootTargeting);
}
}
static SERIALIZER = makeColoredSerializer("Sleestak", Sleestak);
}
export class Tetrite extends AbstractAgent {
constructor(generation) {
const g = generation ?? 0;
const color = g === 0 ? SEAGREEN : g === 1 ? MEDIUMSEAGREEN : DARKSEAGREEN;
super(
"Tetrite",
CARNIVORE | ORGANIC,
NONE,
CTBH.TETRITE,
Symbol.of("∂", color),
);
this._generation = g;
this._targeting = new Targeting()
.dodgeBullets(50)
.attackPlayer(14)
.moveRandomly();
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.TETRITE);
}
onDie(_event, cell) {
if (this._generation < 2) {
this._createTwoMore(cell, new Tetrite(this._generation + 1));
}
}
_createTwoMore(center, child) {
const adj = [];
for (const dir of ADJ_DIRECTIONS) {
const c = center.getAdjacentCell(dir);
if (c != null && c.canEnter(center, child, dir, false)) {
adj.push(c);
}
}
let count = Math.min(2, adj.length);
while (count > 0) {
const idx = Math.floor(Math.random() * adj.length);
const c = adj.splice(idx, 1)[0];
c.setAgent(new Tetrite(child._generation));
count--;
}
}
onFrame(_event, cell, frame) {
if (frame % 5 === 0) {
game.agentMove(cell, this, this._targeting);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([gen]) {
return new Tetrite(parseInt(gen) || 0);
}
store(t) {
return `Tetrite|${t._generation}`;
}
example() {
return new Tetrite(0);
}
template(_id) {
return "Tetrite|{generation}";
}
tag() {
return "Agents";
}
})();
}
export class Thermadon extends AbstractAgent {
constructor(color) {
super(
"Thermadon",
CARNIVORE | ORGANIC | FIRE_RESISTANT,
color,
CTBH.THERMADON,
Symbol.of("Ð", FIREBRICK),
);
this._movTargeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(14)
.moveRandomly()
.trackPlayer();
this._shootTargeting = new Targeting().attackPlayer(6);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.THERMADON);
}
onFrame(_event, cell, frame) {
if (frame % 15 === 0) {
game.agentShoot(
cell,
this,
Registry.get("Fireball"),
this._shootTargeting,
);
} else if (frame % 7 === 0) {
game.agentMove(cell, this, this._movTargeting);
}
}
static SERIALIZER = makeColoredSerializer("Thermadon", Thermadon);
}
export class Triffid extends AbstractAgent {
constructor(color) {
super("A Triffid", ORGANIC, color, CTBH.TRIFFID, Symbol.of("¥", DARKGREEN));
this._movTargeting = new Targeting().attackPlayer(20).moveRandomly();
this._dartTargeting = new Targeting().attackPlayer(10);
}
onHit(event, _attackerLoc, agentLoc, agent) {
agent.changeHealth(DAMAGE.TRIFFID);
}
onFrame(_event, cell, frame) {
if (frame % 25 === 0) {
game.agentMove(cell, this, this._movTargeting);
} else if (frame % 8 === 0) {
game.agentShoot(
cell,
this,
Registry.get("PoisonDart"),
this._dartTargeting,
);
}
}
static SERIALIZER = makeColoredSerializer("Triffid", Triffid);
}
export class Tumbleweed extends AbstractAgent {
constructor(direction) {
super(
"Tumbleweed",
ORGANIC,
NONE,
INDESTRUCTIBLE,
Symbol.of("*", SADDLEBROWN),
);
if (!direction) {
throw new Error("Tumbleweed needs a direction");
}
this.direction = direction;
this._targeting = new Targeting();
}
changeHealth(_v) {
return 100;
}
onHitBy(event, agentLoc, agent, dir) {
const newDir = findPathInDirection(
agentLoc.board,
agentLoc,
agent,
null,
dir,
this._targeting,
);
if (newDir != null) {
const myCell = agentLoc.board.getAdjacentCell(
agentLoc.x,
agentLoc.y,
dir,
);
game.agentMoveInDirection(event, myCell, this, newDir);
} else {
event.cancel();
}
}
onFrame(_event, cell, frame) {
if (frame % 20 === 0) {
const next = cell.board.getAdjacentCell(cell.x, cell.y, this.direction);
if (next != null) {
const dir = findPathInDirection(
cell.board,
cell,
this,
null,
this.direction,
this._targeting,
);
if (dir != null) {
const event = game.createEvent();
game.agentMoveInDirection(event, cell, this, dir);
}
} else {
// At board edge: wrap to the opposite side.
let wrapCell = TerrainUtils.getCellOnOppositeSide(cell, this.direction);
if (wrapCell && wrapCell.canEnter(cell, this, this.direction, false)) {
cell.removeAgent(this);
wrapCell.setAgent(this);
return;
}
// Try a lateral path then wrap from that position.
const dir = findPathInDirection(
cell.board,
cell,
this,
null,
this.direction,
this._targeting,
);
if (dir != null) {
const lateralCell = cell.board.getAdjacentCell(cell.x, cell.y, dir);
if (lateralCell) {
wrapCell = TerrainUtils.getCellOnOppositeSide(
lateralCell,
this.direction,
);
if (
wrapCell &&
wrapCell.canEnter(cell, this, this.direction, false)
) {
cell.removeAgent(this);
wrapCell.setAgent(this);
return;
}
}
}
// Last resort: remove the tumbleweed from the board.
cell.removeAgent(this);
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir]) {
return new Tumbleweed(directionByName(dir));
}
store(t) {
return `Tumbleweed|${t.direction.name}`;
}
example() {
return new Tumbleweed(WEST);
}
template(_id) {
return "Tumbleweed|{direction}";
}
tag() {
return "Outside Terrain";
}
})();
}
// ── Statue ────────────────────────────────────────────────────────────────────
/**
* Statue — an indestructible agent-proxy that wraps another agent.
* On a color event it transforms back into the wrapped agent.
* Visually it appears stone-grey; it cannot be killed.
*/
export class Statue extends AgentProxy {
constructor(agent, color) {
super(agent, 0);
this.name = `${agent.name} Statue`;
this.color = color;
this.symbol = Symbol.of(
agent.symbol.entity ?? "\u2503",
LESSNEARBLACK,
null,
BUILDING_WALL,
null,
);
}
changeHealth(_v) {
return 100;
}
onFrame(event, cell, frame) {
if (this.is(PLAYER)) {
super.onFrame(event, cell, frame);
}
}
onHitBy(event, _agentLoc, _agent, _dir) {
event.cancel();
}
onColorEvent(_event, color, cell) {
if (color == this.color && this.agent) {
cell.setAgent(this.agent);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
const agent =
typeof args[0] === "string" ? _tryGetAgent(args[0]) : args[0];
const color = colorByName(args[1]) ?? NONE;
return new Statue(agent, color);
}
store(s) {
return `Statue|${this.esc(s.agent)}|${s.color.name}`;
}
example() {
return new Statue(new Boulder(), NONE);
}
template(_id) {
return "Statue|{agent}|{color}";
}
tag() {
return "Room Features";
}
})();
}
// ── Paralyzed ─────────────────────────────────────────────────────────────────
/**
* An AgentProxy decorator that renders the underlying agent paralyzed for
* 40 animation frames, then restores the original agent.
*
* Visually the wrapped agent's symbol is tinted with a DARKVIOLET background.
* AI (`onTurn`) is suppressed by AgentProxy for the duration.
* If wrapping the player, the PARALYZED flag is removed on restoration.
*
* Mirrors Java's `Paralyzed` class.
*/
export class Paralyzed extends AgentProxy {
constructor(agent) {
super(agent, NOT_EDITABLE);
// Paralyzed symbol: same glyph and color, DARKVIOLET background
const sym = agent.symbol;
this.symbol = Symbol.of(
sym.entity,
sym.color,
DARKVIOLET,
sym.outsideColor,
DARKVIOLET,
);
}
onFrame(event, cell, frame) {
if (frame <= 40) {
// Keep the wrapped player's animations running during paralysis.
if (this.is(PLAYER) && this.agent.onFrame) {
this.agent.onFrame(event, cell, frame);
}
return;
}
// After 40 frames restore the original agent.
if (this.is(PLAYER)) {
event.player.remove(PARALYZED);
}
cell.setAgent(this.agent);
}
static SERIALIZER = new (class extends BaseSerializer {
create([agentArg]) {
const agent =
typeof agentArg === "string" ? _tryGetAgent(agentArg) : agentArg;
return new Paralyzed(agent);
}
store(p) {
return `Paralyzed|${this.esc(p.agent)}`;
}
example() {
return new Paralyzed(new Boulder());
}
template(_id) {
return "Paralyzed|{agent}";
}
tag() {
return "Room Features";
}
})();
}
// ── Salmon ────────────────────────────────────────────────────────────────────
/** Salmon — passive organic aquatic creature; lives in water. */
export class Salmon extends AbstractAgent {
constructor() {
super(
"Salmon",
ORGANIC | AQUATIC,
SALMONCOLOR,
0,
Symbol.of("α", SALMONCOLOR),
);
}
canEnter(_dir, _from, to) {
return to.terrain.is(AQUATIC);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Salmon");
}
create(_args) {
return new Salmon();
}
tag() {
return "Agents";
}
})();
}
// ── Registry ──────────────────────────────────────────────────────────────────
export function registerCreatures() {
Registry.register("Boulder", Boulder.SERIALIZER);
Registry.register("RollingBoulder", RollingBoulder.SERIALIZER);
Registry.register("Pusher", Pusher.SERIALIZER);
Registry.register("Slider", Slider.SERIALIZER);
Registry.register("Campfire", Campfire.SERIALIZER);
Registry.register("Pillar", Pillar.SERIALIZER);
Registry.register("Asciiroth", Asciiroth.SERIALIZER);
Registry.register("Cephalid", Cephalid.SERIALIZER);
Registry.register("Corvid", Corvid.SERIALIZER);
Registry.register("Farthapod", Farthapod.SERIALIZER);
Registry.register("GreatOldOne", GreatOldOne.SERIALIZER);
Registry.register("Hooloovoo", Hooloovoo.SERIALIZER);
Registry.register("KillerBee", KillerBee.SERIALIZER);
Registry.register("LavaWorm", LavaWorm.SERIALIZER);
Registry.register("LightningLizard", LightningLizard.SERIALIZER);
Registry.register("Optilisk", Optilisk.SERIALIZER);
Registry.register("Rhindle", Rhindle.SERIALIZER);
Registry.register("Sleestak", Sleestak.SERIALIZER);
Registry.register("Thermadon", Thermadon.SERIALIZER);
Registry.register("Triffid", Triffid.SERIALIZER);
Registry.register("Tetrite", Tetrite.SERIALIZER);
Registry.register("Tumbleweed", Tumbleweed.SERIALIZER);
Registry.register("Statue", Statue.SERIALIZER);
Registry.register("Paralyzed", Paralyzed.SERIALIZER);
Registry.register("Salmon", Salmon.SERIALIZER);
}
function _tryGetAgent(key) {
try {
return Registry.get(key);
} catch (_) {
return null;
}
}