import { Terrain } from "../../core/terrain.js";
import { Registry } from "../../core/registry.js";
import {
EuclideanShard,
FishingPole,
GoldCoin,
Grenade,
Rock,
} from "../../pieces/items/items.js";
import { TerrainUtils } from "../../core/terrain-utils.js";
import { events } from "../../core/events.js";
import { ImmobileAgent } from "../../pieces/agents/creatures.js";
import {
TRAVERSABLE,
PENETRABLE,
ETHEREAL,
HIDES_ITEMS,
AMMO_ENLIVENER,
AQUATIC,
POISONED,
WEAK,
} from "../../core/flags.js";
import {
BARELY_BUILDING_WALL,
BLACK,
BUILDING_FLOOR,
BUILDING_WALL,
BURLYWOOD,
BURNTWOOD,
BUSHES,
CLIFFS,
DARK_PIER,
DARKGOLDENROD,
DARKKHAKI,
DARKSLATEBLUE,
DARKSLATEGRAY,
FOREST,
FORESTGREEN,
GHOSTWHITE,
GOLD,
GOLDENROD,
GRASS,
HIGH_ROCKS,
LESSNEARBLACK,
LIGHTSLATEGRAY,
LIGHTSTEELBLUE,
LOW_ROCKS,
NEARBLACK,
NONE,
OCEAN,
ORANGE,
PIER,
RED,
SALMON,
SAND,
SIENNA,
SILVER,
SKYBLUE,
SURF,
TAN,
VERYBLACK,
VIOLET,
WHITE,
WOOD_PILING,
YELLOW,
colorByName,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { stateFromString, ON, OFF } from "../../core/state.js";
import {
directionByName,
NORTH,
SOUTH,
EAST,
WEST,
NORTHEAST,
NONE as DIR_NONE,
} from "../../core/direction.js";
import { game } from "../../core/game.js";
import { getRandomDirection } from "../agents/targeting.js";
const EMPTY_HANDED = "Empty-handed";
// ── Basic / Stateless Terrain ─────────────────────────────────────────────────
class Floor extends Terrain {
constructor() {
super(
"Floor",
TRAVERSABLE | PENETRABLE,
Symbol.of("\u2003", VERYBLACK, null, BLACK, BUILDING_FLOOR),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Floor");
}
create(_args) {
return new Floor();
}
tag() {
return "Terrain";
}
})();
}
class Wall extends Terrain {
constructor() {
super("Wall", Symbol.of("\u2003", null, NEARBLACK, null, BUILDING_WALL));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Wall");
}
create(_args) {
return new Wall();
}
tag() {
return "Terrain";
}
})();
}
class Dirt extends Terrain {
constructor() {
super("Dirt", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, TAN));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Dirt");
}
create(_args) {
return new Dirt();
}
tag() {
return "Outside Terrain";
}
})();
}
class Field extends Terrain {
constructor() {
super("Field", TRAVERSABLE | PENETRABLE, Symbol.of("„", FORESTGREEN, TAN));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Field");
}
create(_args) {
return new Field();
}
tag() {
return "Outside Terrain";
}
})();
}
class Forest extends Terrain {
constructor() {
super(
"Forest",
TRAVERSABLE | PENETRABLE,
Symbol.of("\u2003", null, FOREST),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Forest");
}
create(_args) {
return new Forest();
}
tag() {
return "Outside Terrain";
}
})();
}
/**
* Crevasse — an impassable chasm. Appears as a plain black cell until the
* player is adjacent, at which point it reveals itself as "≈" on a very-dark
* background. Has no outside representation (looks the same in both modes).
*/
class Crevasse extends Terrain {
static REVEALED = Symbol.of("\u2248", BLACK, VERYBLACK);
static NOT_REVEALED = Symbol.of("\u2003", NONE, BLACK);
constructor() {
super("Crevasse", ETHEREAL | PENETRABLE, Crevasse.NOT_REVEALED);
}
onEnter(event, _player, cell, _dir) {
event.cancelWithMessage(cell, "A deep crevasse spans before you");
}
onAdjacentTo(_event, cell) {
cell._animTerrainSymbol = Crevasse.REVEALED;
cell.board._notifyCellChange(cell);
}
onNotAdjacentTo(_event, cell) {
cell._animTerrainSymbol = null;
cell.board._notifyCellChange(cell);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Crevasse");
}
create(_args) {
return new Crevasse();
}
tag() {
return "Terrain";
}
})();
}
/** Sky — ethereal: only fliers can move here, but items pass through */
class Sky extends Terrain {
constructor() {
super("Sky", ETHEREAL | PENETRABLE, Symbol.of("\u2003", null, SKYBLUE));
}
onDrop(event, cell, _item) {
event.cancel();
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Sky");
}
create(_args) {
return new Sky();
}
tag() {
return "Outside Terrain";
}
})();
}
/** Cloud — traversable; items cannot be dropped here */
class Cloud extends Terrain {
constructor() {
super(
"Cloud",
TRAVERSABLE | PENETRABLE,
Symbol.of("\u2003", null, GHOSTWHITE),
);
}
onDrop(event, cell, _item) {
event.cancel();
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Cloud");
}
create(_args) {
return new Cloud();
}
tag() {
return "Outside Terrain";
}
})();
}
class ChalkedFloor extends Terrain {
constructor() {
super(
"Chalked Floor",
TRAVERSABLE | PENETRABLE,
Symbol.of("✗", LESSNEARBLACK, null, NEARBLACK, BUILDING_FLOOR),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("ChalkedFloor");
}
create(_args) {
return new ChalkedFloor();
}
tag() {
return "Terrain";
}
})();
}
/** TrashPile — HIDES_ITEMS: items are not visible until you step on it */
class TrashPile extends Terrain {
constructor() {
super(
"Trash Pile",
TRAVERSABLE | PENETRABLE | HIDES_ITEMS,
Symbol.of("%", SIENNA, BUILDING_FLOOR, BLACK, BUILDING_FLOOR),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("TrashPile");
}
create(_args) {
return new TrashPile();
}
tag() {
return "Room Features";
}
})();
}
/** Haystack — HIDES_ITEMS, with outdoor color variant */
class Haystack extends Terrain {
constructor() {
super(
"Haystack",
TRAVERSABLE | PENETRABLE | HIDES_ITEMS,
Symbol.of("λ", GOLDENROD, DARKGOLDENROD, BLACK, TAN),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Haystack");
}
create(_args) {
return new Haystack();
}
tag() {
return "Outside Terrain";
}
})();
}
class Pier extends Terrain {
constructor() {
super("Pier", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, PIER));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Pier");
}
create(_args) {
return new Pier();
}
tag() {
return "Outside Terrain";
}
})();
}
class WoodPiling extends Terrain {
constructor() {
super("Wood Piling", Symbol.of("\u2003", null, WOOD_PILING));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("WoodPiling");
}
create(_args) {
return new WoodPiling();
}
tag() {
return "Terrain";
}
})();
}
class Sand extends Terrain {
constructor() {
super("Sand", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, SAND));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Sand");
}
create(_args) {
return new Sand();
}
tag() {
return "Outside Terrain";
}
})();
}
class LowRocks extends Terrain {
constructor() {
super(
"Low Rocks",
TRAVERSABLE | PENETRABLE,
Symbol.of("\u2003", null, LOW_ROCKS),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("LowRocks");
}
create(_args) {
return new LowRocks();
}
tag() {
return "Outside Terrain";
}
})();
}
/** HighRocks — TRAVERSABLE; blocks in-flight items (arrows etc.) */
class HighRocks extends Terrain {
constructor() {
super(
"High Rocks",
TRAVERSABLE | PENETRABLE,
Symbol.of("⛰", HIGH_ROCKS, LOW_ROCKS), // alt: ^
);
}
onFlyOver(event, _cell, _flier) {
event.cancel();
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("HighRocks");
}
create(_args) {
return new HighRocks();
}
tag() {
return "Outside Terrain";
}
})();
}
/** ImpassableCliffs — totally impassable rock face */
class ImpassableCliffs extends Terrain {
constructor() {
super("Impassable Cliffs", Symbol.of("\u0394", BLACK, HIGH_ROCKS));
}
onEnter(event, _player, cell, _dir) {
events.fireMessage(cell, "The rocks are too steep to climb");
event.cancel();
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("ImpassableCliffs");
}
create(_args) {
return new ImpassableCliffs();
}
tag() {
return "Outside Terrain";
}
})();
}
/** PyramidWall — impassable, tinted with a designer-chosen color */
export class PyramidWall extends Terrain {
constructor(color) {
super(
color.name + " Pyramid Wall",
0,
color,
Symbol.of("\u2003", null, color),
);
}
static SERIALIZER = new (class extends BaseSerializer {
create([color]) {
return new PyramidWall(colorByName(color) ?? NONE);
}
store(pw) {
return `PyramidWall|${pw.color.name}`;
}
example() {
return new PyramidWall(NONE);
}
template(_id) {
return "PyramidWall|{color}";
}
tag() {
return "Outside Terrain";
}
})();
}
/** Fence — purely cosmetic barrier (PENETRABLE only, no TRAVERSABLE) */
class Fence extends ImmobileAgent {
constructor(direction) {
if (direction != NORTH && direction != EAST && direction != DIR_NONE) {
throw new Error("Fence direction must be north, east or none.");
}
const symbol = Symbol.of(
direction == EAST ? "=" : direction == NORTH ? "I" : "‡",
BURLYWOOD,
null,
BURNTWOOD,
null,
);
super("Fence", PENETRABLE, symbol);
this.direction = direction;
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir]) {
return new Fence(directionByName(dir));
}
store(f) {
return `Fence|${f.direction?.name ?? f.direction}`;
}
example() {
return new Fence(NORTH);
}
template(_id) {
return "Fence|{direction}";
}
tag() {
return "Outside Terrain";
}
})();
}
class Boards extends Terrain {
constructor() {
super(
"Boards",
TRAVERSABLE | PENETRABLE,
Symbol.of("≠", BARELY_BUILDING_WALL, BUILDING_FLOOR),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Boards");
}
create(_args) {
return new Boards();
}
tag() {
return "Room Features";
}
})();
}
/**
* Bridge — only N/S/E/W movement allowed; diagonal and vertical blocked.
* The background mirrors the terrain below it (water, lava, etc.).
*/
export class Bridge extends Terrain {
/** @param {string} terrainBg background color of underlying terrain */
constructor(terrainBg) {
super(
"Bridge",
TRAVERSABLE | PENETRABLE,
Symbol.of("\u2261", DARK_PIER, terrainBg),
);
this._terrainBg = terrainBg;
}
canEnter(_agent, _cell, direction) {
return !direction.isDiagonal() && !direction.isVertical();
}
canExit(_agent, _cell, direction) {
return !direction.isDiagonal() && !direction.isVertical();
}
onEnter(event, _player, _cell, dir) {
if (dir.isDiagonal() || dir.isVertical()) event.cancel();
}
onExit(event, _player, _cell, dir) {
if (dir.isDiagonal() || dir.isVertical()) event.cancel();
}
static SERIALIZER = new (class extends BaseSerializer {
create([color]) {
return new Bridge(colorByName(color) ?? OCEAN);
}
store(b) {
return `Bridge|${b._terrainBg.name}`;
}
example() {
return new Bridge(OCEAN);
}
template(_id) {
return "Bridge|{color}";
}
tag() {
return "Outside Terrain";
}
})();
}
/** Flowers — decorative ground cover tinted with a color */
export class Flowers extends Terrain {
constructor(color) {
super(
color.name + " Flowers",
TRAVERSABLE | PENETRABLE,
color,
Symbol.of("⚘" /*"ϊ"*/, color, GRASS),
);
}
static SERIALIZER = new (class extends BaseSerializer {
create([color]) {
return new Flowers(colorByName(color) ?? NONE);
}
store(f) {
return `Flowers|${f.color.name}`;
}
example() {
return new Flowers(NONE);
}
template(_id) {
return "Flowers|{color}";
}
tag() {
return "Outside Terrain";
}
})();
}
// ── Grasses ───────────────────────────────────────────────────────────────────
class Grass extends Terrain {
constructor() {
super("Grass", TRAVERSABLE | PENETRABLE, Symbol.of("\u2003", null, GRASS));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Grass");
}
create(_args) {
return new Grass();
}
tag() {
return "Outside Terrain";
}
})();
}
class TallGrass extends Terrain {
constructor() {
super(
"Tall Grass",
TRAVERSABLE | PENETRABLE,
Symbol.of("…", BUSHES, GRASS),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("TallGrass");
}
create(_args) {
return new TallGrass();
}
tag() {
return "Grasses";
}
})();
}
class BunchGrass extends Terrain {
constructor() {
super(
"Bunch Grass",
TRAVERSABLE | PENETRABLE,
Symbol.of("…", FOREST, GRASS),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("BunchGrass");
}
create(_args) {
return new BunchGrass();
}
tag() {
return "Grasses";
}
})();
}
class BeachGrass extends Terrain {
constructor() {
super(
"Beach Grass",
TRAVERSABLE | PENETRABLE,
Symbol.of("…", DARKKHAKI, SAND),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("BeachGrass");
}
create(_args) {
return new BeachGrass();
}
tag() {
return "Grasses";
}
})();
}
class SwampGrass extends Terrain {
constructor() {
super(
"Swamp Grass",
TRAVERSABLE | PENETRABLE,
Symbol.of("…", GRASS, BUSHES),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("SwampGrass");
}
create(_args) {
return new SwampGrass();
}
tag() {
return "Grasses";
}
})();
}
class Scrub extends Terrain {
constructor() {
super("Scrub", TRAVERSABLE | PENETRABLE, Symbol.of("…", BUSHES, SAND));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Scrub");
}
create(_args) {
return new Scrub();
}
tag() {
return "Grasses";
}
})();
}
class Weeds extends Terrain {
constructor() {
super("Weeds", TRAVERSABLE | PENETRABLE, Symbol.of("…", GRASS, TAN));
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Weeds");
}
create(_args) {
return new Weeds();
}
tag() {
return "Grasses";
}
})();
}
// ── New Terrain ───────────────────────────────────────────────────────────────
/** Bushes — traversable outdoor ground cover */
class Bushes extends Terrain {
constructor() {
super(
"Bushes",
PENETRABLE | TRAVERSABLE,
Symbol.of("\u2003", null, BUSHES),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Bushes");
}
create(_args) {
return new Bushes();
}
tag() {
return "Outside Terrain";
}
})();
}
/**
* Throne — directional seat; only enterable from the facing direction
* and only exitable in the facing direction.
*/
export class Throne extends Terrain {
constructor(direction) {
super(
"Throne",
TRAVERSABLE | PENETRABLE,
Symbol.of(
direction === NORTH ? "◛" : "◚",
SILVER,
BLACK,
BUILDING_WALL,
BUILDING_FLOOR,
),
);
this._direction = direction;
}
onEnter(event, _player, _cell, dir) {
const required = this._direction === NORTH ? SOUTH : NORTH;
if (dir !== required) event.cancel();
}
onExit(event, _player, _cell, dir) {
if (dir !== this._direction) event.cancel();
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir]) {
return new Throne(directionByName(dir) ?? NORTH);
}
store(t) {
return `Throne|${t._direction.name}`;
}
example() {
return new Throne(NORTH);
}
template(_id) {
return "Throne|{direction}";
}
tag() {
return "Room Features";
}
})();
}
/**
* WishingWell — drop a GoldCoin to get healed. Wraps underlying terrain.
*/
export class WishingWell extends Terrain {
constructor(terrain, state) {
super(
"Well",
PENETRABLE,
Symbol.of("Ф", OCEAN, terrain.symbol.getBackground(false)),
);
this._terrain = terrain;
this._state = state;
}
onDrop(event, cell, item) {
if (!(item instanceof Grenade)) {
event.cancelWithMessage(
cell,
item?.getDefiniteNoun("{0} falls into the well") ??
"It falls into the well",
);
}
}
onEnter(event, player, cell, _dir) {
event.cancel();
const sel = player.bag?.getSelected?.() ?? player.bag?.selected;
if (sel instanceof GoldCoin) {
player.bag.remove(sel);
if (this._state.isOn()) {
events.fireMessage(cell, "You toss a coin into the well.");
_wishingWellHeal(player, cell, this, this._state);
} else {
events.fireMessage(
cell,
"You toss a coin into the well. Nothing happens.",
);
}
} else if (sel instanceof Rock) {
events.fireMessage(
cell,
"You toss a rock into the well. Calming, isn't it?",
);
} else if (sel && sel.name !== EMPTY_HANDED) {
events.fireMessage(cell, "Try a coin...");
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
return new WishingWell(_rt(args[0]), stateFromString(args[1]) ?? ON);
}
store(w) {
return `WishingWell|${this.esc(w._terrain)}|${w._state.name}`;
}
example() {
return new WishingWell(new Floor(), ON);
}
template(_id) {
return "WishingWell|{terrain}|{state}";
}
tag() {
return "Room Features";
}
})();
}
function _wishingWellHeal(player, cell, well, state) {
if (player.is(POISONED)) {
player.remove(POISONED);
events.fireMessage(cell, "You are no longer poisoned.");
TerrainUtils.toggleCellState(cell, well, state);
} else if (player.is(WEAK)) {
player.remove(WEAK);
events.fireMessage(cell, "You no longer feel weak.");
TerrainUtils.toggleCellState(cell, well, state);
} else {
const hp = player.changeHealth(0);
if (hp < 100) {
player.changeHealth(hp - 100);
events.fireMessage(cell, "You feel fully healed.");
TerrainUtils.toggleCellState(cell, well, state);
} else {
events.fireMessage(cell, "Nothing happens.");
}
}
}
/**
* Teleporter — transports the player to a specific board/x/y on entry.
* Animates through RED→SALMON→ORANGE→WHITE cycling (mirrors Java Animated impl).
*/
export class Teleporter extends Terrain {
static SYMBOLS = [
Symbol.of("∞", RED, null, RED, BUILDING_FLOOR),
Symbol.of("∞", SALMON, null, SALMON, BUILDING_FLOOR),
Symbol.of("∞", ORANGE, null, ORANGE, BUILDING_FLOOR),
Symbol.of("∞", WHITE, null, WHITE, BUILDING_FLOOR),
];
constructor(boardID, x, y) {
super("Teleporter", TRAVERSABLE | PENETRABLE, Teleporter.SYMBOLS[0]);
this._boardID = boardID;
this._x = x;
this._y = y;
}
onEnter(event, player, _cell, dir) {
player.teleport(event, dir, this._boardID, this._x, this._y);
}
onFrame(event, cell, frame) {
this.symbol = Teleporter.SYMBOLS[frame % 4];
cell.board._notifyCellChange(cell);
}
static SERIALIZER = new (class extends BaseSerializer {
create([boardID, x, y]) {
return new Teleporter(boardID, parseInt(x) || 0, parseInt(y) || 0);
}
store(t) {
return `Teleporter|${t._boardID}|${t._x}|${t._y}`;
}
example() {
return new Teleporter("board1", 0, 0);
}
template(_id) {
return "Teleporter|{boardID}|{x}|{y}";
}
tag() {
return "Room Features";
}
})();
}
/**
* ForceField — when ON, strips all carried items on entry.
* When OFF, behaves like the wrapped terrain.
*/
export class ForceField extends Terrain {
static COLORS = [RED, YELLOW, ORANGE, VIOLET];
constructor(terrain, direction, color, state) {
const bg = terrain.symbol.getBackground(false);
const bgOut = terrain.symbol.getBackground(true);
const sym = state.isOff()
? (terrain.symbol ?? Symbol.of("\u2003", null))
: direction === NORTH
? Symbol.of("|", RED, bg, RED, bgOut)
: Symbol.of("—", RED, bg, RED, bgOut);
super("Force Field", TRAVERSABLE | PENETRABLE, color, sym);
this._direction = direction;
this._state = state;
this._terrain = terrain;
}
onColorEvent(_event, color, cell) {
if (color !== this.color) {
return;
}
TerrainUtils.toggleCellState(cell, this, this._state);
}
randomSeed() {
return true;
}
onFrame(_event, cell, frame) {
if (!this._state.isOn()) {
return;
}
const outside = cell.board.outside;
const bg = this._terrain.symbol.getBackground(outside);
const char = this._direction === NORTH ? "|" : "\u2014";
this.symbol = Symbol.of(char, ForceField.COLORS[frame % 4], bg);
cell.board._notifyCellChange(cell);
}
onEnter(_event, player, cell, dir) {
if (!this._state.isOn()) {
return;
}
const bag = player.bag;
if (!bag) {
return;
}
const entries = [...bag.entries].filter(
(e) => e.piece.name !== EMPTY_HANDED,
);
if (entries.length === 0) {
return;
}
const prevCell = cell.getAdjacentCell?.(dir?.reverse);
for (const entry of entries) {
const count = entry.count;
for (let j = 0; j < count; j++) {
prevCell?.addItem?.(entry.piece);
bag.remove(entry.piece);
}
}
events.fireModalMessage(
"All your stuff gets flung from your body. It's kind of embarrassing.",
);
}
onFlyOver(event, _cell, _flier) {
if (this._state.isOn()) {
event.cancel();
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
if (args.length >= 4) {
return new ForceField(
_rt(args[0]),
directionByName(args[1]) ?? DIR_NONE,
colorByName(args[2]) ?? NONE,
stateFromString(args[3]) ?? ON,
);
}
return new ForceField(
Registry.get("Floor"),
directionByName(args[0]) ?? DIR_NONE,
colorByName(args[1]) ?? NONE,
stateFromString(args[2]) ?? ON,
);
}
store(ff) {
return `ForceField|${this.esc(ff._terrain)}|${ff._direction?.name ?? "none"}|${ff.color.name}|${ff._state.name}`;
}
example() {
return new ForceField(new Floor(), DIR_NONE, NONE, OFF);
}
template(_id) {
return "ForceField|{terrain?}|{direction}|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Reflector — ETHEREAL|AMMO_ENLIVENER, deflects in-flight items.
* Direction: north=|, northeast=/, east=—, southeast=\
*/
export class Reflector extends Terrain {
constructor(direction, color) {
const entity =
direction === NORTH
? "|"
: direction === NORTHEAST
? "/"
: direction === EAST
? "\u2014"
: "\\";
super(
(color && color !== NONE ? color.name + " " : "") + "Reflector",
ETHEREAL | AMMO_ENLIVENER,
color,
Symbol.of(entity, color, SILVER),
);
this._reflectorDir = direction;
}
onEnter(event, _player, cell, _dir) {
event.cancel();
_reflectorRotate(cell, this);
}
onColorEvent(_event, color, cell) {
if (color === this.color) {
_reflectorRotate(cell, this);
}
}
onFlyOver(_event, _cell, flier) {
if (flier.direction) {
const reflected = _reflectedDir(this._reflectorDir, flier.direction);
if (reflected) {
flier.direction = reflected;
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir, color]) {
return new Reflector(
directionByName(dir) ?? EAST,
colorByName(color) ?? NONE,
);
}
store(r) {
return `Reflector|${r._reflectorDir?.name ?? r._reflectorDir}|${r.color.name}`;
}
example() {
return new Reflector(NORTH, RED);
}
template(_id) {
return "Reflector|{direction}|{color}";
}
tag() {
return "Room Features";
}
})();
}
function _reflectorRotate(cell, reflector) {
const dirNames = ["north", "northeast", "east", "southeast"];
const curName = reflector._reflectorDir?.name ?? "east";
const idx = dirNames.indexOf(curName);
const nextName = dirNames[(idx + 1) % dirNames.length];
try {
const key = Registry.serialize(reflector);
const parts = key.split("|");
// key = Reflector|{dir}|{color}
const colorPart = parts[2] ?? parts[1];
cell.setTerrain(Registry.get(`Reflector|${nextName}|${colorPart}`));
} catch (_) {
/* ignore if rotation fails */
}
}
const map = {
north: {
southeast: "southwest",
southwest: "southeast",
northeast: "northwest",
northwest: "northeast",
},
northeast: { south: "west", west: "south", north: "east", east: "north" },
east: {
southeast: "northeast",
northeast: "southeast",
southwest: "northwest",
northwest: "southwest",
},
southeast: { east: "south", south: "east", north: "west", west: "north" },
};
function _reflectedDir(mirror, incoming) {
const mirrorName = mirror.name ?? String(mirror);
const incomingName = incoming.name ?? String(incoming);
const row = map[mirrorName];
if (!row || !row[incomingName]) {
return incoming.reverse ?? incoming;
}
return directionByName(row[incomingName]) ?? incoming;
}
/**
* BeeHive — impassable, spawns KillerBees on color event.
*/
export class BeeHive extends Terrain {
constructor(color, state) {
super("Bee Hive", 0, color, Symbol.of("⌂", DARKGOLDENROD, GOLD));
this._hiveState = state;
this._hiveColor = color;
}
canExit(agent, _cell, _dir) {
return agent?.name === "Killer Bee";
}
onEnter(event, _player, cell, _dir) {
event.cancel();
_annoyHive(cell, this._hiveColor, this._hiveState);
}
onFlyOver(event, cell, _flier) {
event.cancel();
_annoyHive(cell, this._hiveColor, this._hiveState);
}
onColorEvent(_event, color, cell) {
if (color !== this._hiveColor) return;
if (cell.agent == null) {
try {
cell.setAgent(Registry.get(`KillerBee|${this._hiveColor.name}`));
} catch (_) {
/* bee not available */
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([color, state]) {
return new BeeHive(
colorByName(color) ?? NONE,
stateFromString(state) ?? OFF,
);
}
store(bh) {
return `BeeHive|${bh._hiveColor.name}|${bh._hiveState.name}`;
}
example() {
return new BeeHive(NONE, OFF);
}
template(_id) {
return "BeeHive|{color}|{state}";
}
tag() {
return "Agents";
}
})();
}
function _annoyHive(cell, color, state) {
if (state.isOn()) {
return;
}
try {
const bee = Registry.get(`KillerBee|${color.name}`);
const adj = cell.getAdjacentCells() ?? [];
for (const c of adj) {
if (c.agent == null) {
c.setAgent(bee);
break;
}
}
TerrainUtils.toggleCellState(cell, cell.terrain, state);
} catch (_) {
/* ignore */
}
}
/**
* FarthapodNest — impassable, spawns Farthapods on color event.
*/
export class FarthapodNest extends Terrain {
constructor(color) {
super(
"Farthapod Nest",
0,
color,
Symbol.of("≡", LIGHTSTEELBLUE, BLACK, DARKSLATEBLUE, GRASS),
);
this._nestColor = color;
}
canExit(agent, _cell, _dir) {
return agent?.name === "Farthapod";
}
onColorEvent(_event, color, cell) {
if (color !== this._nestColor) return;
if (cell.agent == null) {
try {
cell.setAgent(Registry.get(`Farthapod|${this._nestColor.name}`));
} catch (_) {
/* ignore */
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([color]) {
return new FarthapodNest(colorByName(color) ?? NONE);
}
store(fn) {
return `FarthapodNest|${fn._nestColor.name}`;
}
example() {
return new FarthapodNest(NONE);
}
template(_id) {
return "FarthapodNest|{color}";
}
tag() {
return "Agents";
}
})();
}
/**
* Turnstile — one-way passage (east or west only).
* A color event reverses the direction.
*/
export class Turnstile extends Terrain {
constructor(direction, color) {
const entity = direction === WEST ? "«" : "»";
super(
"Turnstile",
0,
color,
Symbol.of(entity, WHITE, BLACK, BLACK, BUILDING_FLOOR),
);
this._turnDir = direction;
}
canEnter(_agent, _cell, dir) {
return dir === this._turnDir || dir.name === this._turnDir.name;
}
canExit(_agent, _cell, dir) {
return dir === this._turnDir || dir.name === this._turnDir.name;
}
onEnter(event, _player, _cell, dir) {
if (!this.canEnter(null, null, dir)) {
event.cancel();
}
}
onExit(event, _player, _cell, dir) {
if (!this.canExit(null, null, dir)) {
event.cancel();
}
}
onAgentEnter(event, _agent, _cell, dir) {
if (!this.canEnter(null, null, dir)) {
event.cancel();
}
}
onAgentExit(event, _agent, _cell, dir) {
if (!this.canExit(null, null, dir)) {
event.cancel();
}
}
onFlyOver(event, _cell, flier) {
if (!this.canEnter(null, null, flier.direction)) {
event.cancel();
}
}
onColorEvent(_event, color, cell) {
if (color === this.color) {
const newDirName = this._turnDir === EAST ? "west" : "east";
cell.setTerrain(
Registry.get(`Turnstile|${newDirName}|${this.color.name}`),
);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir, color]) {
return new Turnstile(directionByName(dir), colorByName(color) ?? NONE);
}
store(t) {
return `Turnstile|${t._turnDir?.name ?? t._turnDir}|${t.color.name}`;
}
example() {
return new Turnstile(EAST, NONE);
}
template(_id) {
return "Turnstile|{direction}|{color}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Shooter — fires an ammo item in a direction (animated via board's turn cycle).
*/
export class Shooter extends Terrain {
constructor(ammo, direction, color, state) {
super(
"Shooter",
0,
color,
Symbol.of("ж", SALMON, NEARBLACK, SALMON, BUILDING_WALL),
);
this._shootAmmo = ammo;
this._shootDir = direction;
this._shootState = state;
}
onColorEvent(_event, color, cell) {
if (color !== this.color) {
return;
}
TerrainUtils.toggleCellState(cell, this, this._shootState);
}
onFlyOver(event, _cell, flier) {
// Allow only the shooter's own ammo to leave
if (flier.item !== this._shootAmmo) {
event.cancel();
}
}
onFrame(event, cell, frame) {
if (frame % 5 !== 0 || !this._shootState.isOn()) {
return;
}
const dir =
this._shootDir === DIR_NONE ? getRandomDirection() : this._shootDir;
game.shoot(event, cell, this, this._shootAmmo, dir);
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
const ammo = _rt(args[0]);
if (args.length >= 4) {
return new Shooter(
ammo,
directionByName(args[1]) ?? DIR_NONE,
colorByName(args[2]) ?? NONE,
stateFromString(args[3]) ?? ON,
);
}
return new Shooter(
ammo,
DIR_NONE,
colorByName(args[1]) ?? NONE,
stateFromString(args[2]) ?? ON,
);
}
store(s) {
return `Shooter|${this.esc(s._shootAmmo)}|${s._shootDir?.name ?? "none"}|${s.color.name}|${s._shootState.name}`;
}
example() {
return null;
}
template(_id) {
return "Shooter|{ammo}|{direction}|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
/**
* FishPool — a fishing spot on top of existing terrain.
* With a FishingPole selected, player catches a Fish.
*/
export class FishPool extends Terrain {
constructor(terrain, state) {
const bg = terrain.symbol.getBackground(false);
const bgOut = terrain.symbol.getBackground(true);
super(
state.isOn()
? (terrain.name ?? "Water") + " with Fish"
: (terrain.name ?? "Water"),
AQUATIC,
SURF,
state.isOn()
? Symbol.of("α", SURF, bg, SURF, bgOut)
: Symbol.of("\u2003", SURF, bg, SURF, bgOut),
);
this._poolTerrain = terrain;
this._poolState = state;
}
onEnter(event, player, cell, _dir) {
const sel = player.bag.getSelected();
if (this._poolState.isOn() && sel instanceof FishingPole) {
player.bag.add(Registry.get("Fish"));
events.fireMessage(cell, "You caught a fish!");
TerrainUtils.toggleCellState(cell, this, this._poolState);
event.cancel();
}
}
onFrame(event, cell, frame) {
if (frame % 8 !== 0) {
return;
}
const isOn = this._poolState.isOn();
const chanceToChange = isOn ? 40 : 20;
if (Math.random() * 100 >= chanceToChange) {
return;
}
if (isOn) {
this._turnOff(cell);
} else {
TerrainUtils.toggleCellState(cell, this, this._poolState);
}
}
_turnOff(cell) {
// Find adjacent cells that have the same base terrain (not a FishPool)
const board = cell.board;
const candidates = [];
for (const dir of [NORTH, SOUTH, EAST, WEST]) {
const adj = board.getAdjacentCell(cell.x, cell.y, dir);
if (adj && adj.terrain === this._poolTerrain) {
candidates.push(adj);
}
}
const target = candidates.length
? candidates[Math.floor(Math.random() * candidates.length)]
: null;
// Revert this cell to plain base terrain
cell.setTerrain(this._poolTerrain);
if (target) {
// Place an OFF fish pool on the target — it will later turn ON randomly
const offPool = TerrainUtils.getTerrainOtherState(this, this._poolState);
target.setTerrain(offPool);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
return new FishPool(_rt(args[0]), stateFromString(args[1]) ?? ON);
}
store(fp) {
return `FishPool|${this.esc(fp._poolTerrain)}|${fp._poolState.name}`;
}
example() {
return new FishPool(new Floor(), ON);
}
template(_id) {
return "FishPool|{terrain}|{state}";
}
tag() {
return "Outside Terrain";
}
})();
}
/**
* EuclideanEngine — accepts a matching-color EuclideanShard to power up.
*/
export class EuclideanEngine extends Terrain {
constructor(color, state) {
super(
`Euclidean Engine`,
0,
color,
Symbol.of("◊", color, null, color, BUILDING_FLOOR),
);
this._engineState = state;
}
onEnter(event, player, cell, _dir) {
event.cancel();
if (this._engineState.isOn()) {
return;
}
const sel = player.bag.getSelected();
if (sel instanceof EuclideanShard && sel.color === this.color) {
player.bag.remove(sel);
TerrainUtils.toggleCellState(cell, this, this._engineState);
_checkAllEnginesOn(event);
} else if (sel && sel.name !== EMPTY_HANDED) {
events.fireMessage(cell, `The ${sel.name} doesn't seem to help`);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([color, state]) {
return new EuclideanEngine(
colorByName(color) ?? NONE,
stateFromString(state) ?? OFF,
);
}
store(ee) {
return `EuclideanEngine|${ee.color.name}|${ee._engineState.name}`;
}
example() {
return new EuclideanEngine(NONE, OFF);
}
template(_id) {
return "EuclideanEngine|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
function _checkAllEnginesOn(event) {
let count = 0;
event.board.visit((cell) => {
if (
cell.terrain instanceof EuclideanEngine &&
cell.terrain._engineState.isOn()
) {
count++;
}
return false;
});
if (count >= 4) {
event.board.visit((cell) => {
if (
cell.terrain instanceof EuclideanTransporter &&
!cell.terrain._etState.isOn()
) {
events.fireMessage(
cell,
"As the last engine starts, the Euclidean Transporter powers up.",
);
TerrainUtils.toggleCellState(cell, cell.terrain, cell.terrain._etState);
}
return false;
});
}
}
/**
* EuclideanTransporter — teleports to another board when ON.
*/
export class EuclideanTransporter extends Terrain {
constructor(boardID, x, y, state) {
super(
"Euclidean Transporter",
TRAVERSABLE | PENETRABLE,
Symbol.of("Θ", RED, null, RED, BUILDING_FLOOR),
);
this._etBoardID = boardID;
this._etX = x;
this._etY = y;
this._etState = state;
}
onEnter(event, player, cell, dir) {
if (this._etState.isOn()) {
player.teleport(event, dir, this._etBoardID, this._etX, this._etY);
} else {
events.fireMessage(cell, "You're standing on a complex device");
}
}
onAgentEnter(event, _agent, _cell, _dir) {
if (this._etState.isOn()) {
event.cancel();
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
return new EuclideanTransporter(
args[0],
parseInt(args[1]) || 0,
parseInt(args[2]) || 0,
stateFromString(args[3]) ?? OFF,
);
}
store(et) {
return `EuclideanTransporter|${et._etBoardID}|${et._etX}|${et._etY}|${et._etState.name}`;
}
example() {
return new EuclideanTransporter("board1", 0, 0, OFF);
}
template(_id) {
return "EuclideanTransporter|{boardID}|{x}|{y}|{state}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Exchanger — swaps the player's selected item with the top item on the adjacent cell.
*/
export class Exchanger extends Terrain {
constructor() {
super(
"Exchanger",
Symbol.of("÷", LIGHTSLATEGRAY, NEARBLACK, DARKSLATEGRAY, BUILDING_WALL),
);
}
onEnter(event, player, cell, dir) {
event.cancel();
if (dir.isDiagonal()) {
return;
}
const otherCell = cell.getAdjacentCell(dir);
if (!otherCell) {
return;
}
const otherItem = otherCell.topItem ?? null;
const sel = player.bag.getSelected() ?? player.bag.selected;
if (sel instanceof Rock) {
events.fireMessage(cell, "The exchanger does not accept rocks");
} else if (sel && sel.name !== EMPTY_HANDED && otherItem) {
otherCell.removeItem(otherItem);
otherCell.addItem(sel);
player.bag.exchange(otherItem);
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Exchanger");
}
create(_args) {
return new Exchanger();
}
tag() {
return "Room Features";
}
})();
}
/**
* VendingMachine — sells items from the adjacent cell for GoldCoins.
* Format: VendingMachine|{cost}
*/
export class VendingMachine extends Terrain {
constructor(cost) {
super(
`Vending Machine (${cost} coins)`,
Symbol.of("\u00F7", GOLD, NEARBLACK, GOLDENROD, BUILDING_WALL),
);
this._cost = cost;
}
onEnter(event, player, cell, dir) {
event.cancel();
if (dir.isDiagonal()) {
return;
}
const otherCell = cell.getAdjacentCell(dir);
if (!otherCell) {
return;
}
const otherItem = otherCell.bag.last() ?? null;
if (!otherItem) {
events.fireMessage(cell, "The vending machine is empty");
return;
}
const bag = player.bag;
const goldCount = bag?.count?.("Gold Coin") ?? 0;
if (goldCount < this._cost) {
events.fireMessage(cell, `You need ${this._cost} gold coins`);
return;
}
let removed = 0;
while (removed < this._cost) {
const coin = bag?.find?.((i) => i.name === "Gold Coin");
if (!coin) break;
bag.remove(coin);
removed++;
}
otherCell.bag?.remove?.(otherItem);
bag.add?.(otherItem);
events.fireMessage(cell, `You purchase the ${otherItem.name}`);
}
static SERIALIZER = new (class extends BaseSerializer {
create([cost]) {
return new VendingMachine(parseInt(cost) || 1);
}
store(vm) {
return `VendingMachine|${vm._cost}`;
}
example() {
return new VendingMachine(1);
}
template(_id) {
return "VendingMachine|{cost}";
}
tag() {
return "Room Features";
}
})();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Resolve a terrain arg that may be a plain string key or an already-resolved Piece. */
function _rt(arg) {
if (typeof arg === "string") return Registry.get(arg);
return arg;
}
// ── Registry ──────────────────────────────────────────────────────────────────
export function registerBasicTerrain() {
Registry.register("Crevasse", Crevasse.SERIALIZER);
Registry.register("Floor", Floor.SERIALIZER);
Registry.register("Wall", Wall.SERIALIZER);
Registry.register("Dirt", Dirt.SERIALIZER);
Registry.register("Field", Field.SERIALIZER);
Registry.register("Forest", Forest.SERIALIZER);
Registry.register("Sky", Sky.SERIALIZER);
Registry.register("Cloud", Cloud.SERIALIZER);
Registry.register("ChalkedFloor", ChalkedFloor.SERIALIZER);
Registry.register("TrashPile", TrashPile.SERIALIZER);
Registry.register("Haystack", Haystack.SERIALIZER);
Registry.register("Pier", Pier.SERIALIZER);
Registry.register("WoodPiling", WoodPiling.SERIALIZER);
Registry.register("Sand", Sand.SERIALIZER);
Registry.register("LowRocks", LowRocks.SERIALIZER);
Registry.register("HighRocks", HighRocks.SERIALIZER);
Registry.register("ImpassableCliffs", ImpassableCliffs.SERIALIZER);
Registry.register("PyramidWall", PyramidWall.SERIALIZER);
Registry.register("Fence", Fence.SERIALIZER);
Registry.register("Boards", Boards.SERIALIZER);
Registry.register("Bridge", Bridge.SERIALIZER);
Registry.register("Flowers", Flowers.SERIALIZER);
Registry.register("Grass", Grass.SERIALIZER);
Registry.register("TallGrass", TallGrass.SERIALIZER);
Registry.register("BunchGrass", BunchGrass.SERIALIZER);
Registry.register("BeachGrass", BeachGrass.SERIALIZER);
Registry.register("SwampGrass", SwampGrass.SERIALIZER);
Registry.register("Scrub", Scrub.SERIALIZER);
Registry.register("Weeds", Weeds.SERIALIZER);
Registry.register("Bushes", Bushes.SERIALIZER);
Registry.register("Throne", Throne.SERIALIZER);
Registry.register("WishingWell", WishingWell.SERIALIZER);
Registry.register("Teleporter", Teleporter.SERIALIZER);
Registry.register("ForceField", ForceField.SERIALIZER);
Registry.register("Reflector", Reflector.SERIALIZER);
Registry.register("BeeHive", BeeHive.SERIALIZER);
Registry.register("FarthapodNest", FarthapodNest.SERIALIZER);
Registry.register("Turnstile", Turnstile.SERIALIZER);
Registry.register("Shooter", Shooter.SERIALIZER);
Registry.register("FishPool", FishPool.SERIALIZER);
Registry.register("EuclideanEngine", EuclideanEngine.SERIALIZER);
Registry.register("EuclideanTransporter", EuclideanTransporter.SERIALIZER);
Registry.register("Exchanger", Exchanger.SERIALIZER);
Registry.register("VendingMachine", VendingMachine.SERIALIZER);
}