import {
AbstractBoulder,
Pusher,
Slider,
} from "../../pieces/agents/creatures.js";
import { Terrain } from "../../core/terrain.js";
import { Registry } from "../../core/registry.js";
import { TerrainUtils } from "../../core/terrain-utils.js";
import { events } from "../../core/events.js";
import {
is,
not,
getFlag,
description,
DETECT_HIDDEN,
PLAYER,
TRAVERSABLE,
PENETRABLE,
} from "../../core/flags.js";
import {
WHITE,
BLACK,
colorByName,
NONE,
NEARBLACK,
BARELY_BUILDING_WALL,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { TypeOnlySerializer, BaseSerializer } from "../../core/serializer.js";
import { game } from "../../core/game.js";
import { stateFromString } from "../../core/state.js";
import { DOWN } from "../../core/direction.js";
// ── Decorator base class ──────────────────────────────────────────────────────
/**
* Abstract base class for terrain decorators.
*
* A decorator wraps another terrain and augments its behavior. It uses the
* template-method pattern: the base class delegates all terrain callbacks to
* the wrapped terrain first, then calls `*Internal` hook methods for the
* decorator subclass.
*
* The wrapped terrain's flags, name, and symbol are inherited unless overridden.
*/
export class Decorator extends Terrain {
/**
* @param {Terrain} terrain - wrapped terrain
* @param {string} name - override name, or null to inherit from terrain
* @param {number} flags - flag mask (use 0 to inherit from terrain)
* @param {string} color - color (use null to inherit from terrain)
* @param {Symbol} symbol - symbol (use null to inherit)
*/
constructor(terrain, name, flags, color, symbol) {
super(name ?? terrain.name, flags, color ?? NONE, symbol ?? terrain.symbol);
/** @type {Terrain} */
this.terrain = terrain;
}
// Delegate flag checks to underlying terrain
is(flag) {
return is(flag, this.terrain.flags);
}
not(flag) {
return not(flag, this.terrain.flags);
}
/** Return the terrain being wrapped (TerrainProxy interface). */
getProxiedTerrain() {
return this.terrain;
}
// canEnter / canExit delegate to wrapped terrain
canEnter(agent, cell, direction) {
return this.terrain.canEnter(agent, cell, direction);
}
canExit(agent, cell, direction) {
return this.terrain.canExit(agent, cell, direction);
}
// ── Full delegation + Internal hooks ─────────────────────────────────────
onEnter(event, player, cell, dir) {
this.terrain.onEnter(event, player, cell, dir);
this.onEnterInternal(event, player, cell, dir);
}
onExit(event, player, cell, dir) {
this.terrain.onExit(event, player, cell, dir);
this.onExitInternal(event, player, cell, dir);
}
onAgentEnter(event, agent, cell, dir) {
this.terrain.onAgentEnter(event, agent, cell, dir);
this.onAgentEnterInternal(event, agent, cell, dir);
}
onAgentExit(event, agent, cell, dir) {
this.terrain.onAgentExit(event, agent, cell, dir);
this.onAgentExitInternal(event, agent, cell, dir);
}
onFlyOver(event, cell, flier) {
this.terrain.onFlyOver(event, cell, flier);
this.onFlyOverInternal(event, cell, flier);
}
onDrop(event, cell, item) {
this.terrain.onDrop(event, cell, item);
this.onDropInternal(event, cell, item);
}
onPickup(event, loc, agent, item) {
this.terrain.onPickup(event, loc, agent, item);
this.onPickupInternal(event, loc, agent, item);
}
onAdjacentTo(event, cell) {
this.terrain.onAdjacentTo(event, cell);
this.onAdjacentToInternal(event, cell);
}
onNotAdjacentTo(event, cell) {
this.terrain.onNotAdjacentTo(event, cell);
this.onNotAdjacentToInternal(event, cell);
}
onColorEvent(event, color, cell) {
// Forward to wrapped terrain if it handles color events
this.terrain.onColorEvent?.(event, color, cell);
this.onColorEventInternal(event, color, cell);
}
// ── Internal hooks (no-ops; override in subclasses) ───────────────────────
onEnterInternal(_event, _player, _cell, _dir) {}
onExitInternal(_event, _player, _cell, _dir) {}
onAgentEnterInternal(_event, _agent, _cell, _dir) {}
onAgentExitInternal(_event, _agent, _cell, _dir) {}
onFlyOverInternal(_event, _cell, _flier) {}
onDropInternal(_event, _cell, _item) {}
onPickupInternal(_event, _loc, _agent, _item) {}
onAdjacentToInternal(_event, _cell) {}
onNotAdjacentToInternal(_event, _cell) {}
onColorEventInternal(_event, _color, _cell) {}
}
// ── DualTerrain ───────────────────────────────────────────────────────────────
/**
* Holds two terrains and activates one at a time based on state.
* A color event toggles between terrain1 (ON) and terrain2 (OFF).
*/
export class DualTerrain extends Terrain {
constructor(terrain1, terrain2, state, color) {
const active = state.isOn() ? terrain1 : terrain2;
super(active.name, active.flags, color ?? NONE, active.symbol);
this.terrain1 = terrain1;
this.terrain2 = terrain2;
this.state = state;
this._color = color;
}
get _activeTerrain() {
return this.state.isOn() ? this.terrain1 : this.terrain2;
}
is(flag) {
return is(flag, this._activeTerrain.flags);
}
not(flag) {
return not(flag, this._activeTerrain.flags);
}
getProxiedTerrain() {
return this._activeTerrain;
}
get name() {
return this._activeTerrain.name;
}
set name(_) {}
get symbol() {
return this._activeTerrain.symbol;
}
set symbol(_) {}
onColorEvent(event, color, cell) {
if (color === this._color) {
TerrainUtils.toggleCellState(cell, this, this.state);
}
}
canEnter(a, c, d) {
return this._activeTerrain.canEnter(a, c, d);
}
canExit(a, c, d) {
return this._activeTerrain.canExit(a, c, d);
}
onEnter(e, p, c, d) {
this._activeTerrain.onEnter(e, p, c, d);
}
onExit(e, p, c, d) {
this._activeTerrain.onExit(e, p, c, d);
}
onAgentEnter(e, a, c, d) {
this._activeTerrain.onAgentEnter(e, a, c, d);
}
onAgentExit(e, a, c, d) {
this._activeTerrain.onAgentExit(e, a, c, d);
}
onFlyOver(e, c, f) {
this._activeTerrain.onFlyOver(e, c, f);
}
onDrop(e, c, i) {
this._activeTerrain.onDrop(e, c, i);
}
onPickup(e, l, a, i) {
this._activeTerrain.onPickup(e, l, a, i);
}
onAdjacentTo(e, c) {
this._activeTerrain.onAdjacentTo(e, c);
}
onNotAdjacentTo(e, c) {
this._activeTerrain.onNotAdjacentTo(e, c);
}
static SERIALIZER = new (class extends BaseSerializer {
create([t1, t2, state, color]) {
return new DualTerrain(
_rt(t1),
_rt(t2),
stateFromString(state),
colorByName(color) ?? NONE,
);
}
store(d) {
return `DualTerrain|${this.esc(d.terrain1)}|${this.esc(d.terrain2)}|${d.state.name}|${d._color.name}`;
}
example() {
return new DualTerrain(
Registry.get("Floor"),
Registry.get("Wall"),
stateFromString("on"),
NONE,
);
}
template(_id) {
return "DualTerrain|{terrain}|{terrain}|{state}|{color}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* A sign on top of traversable terrain — shows a message when the player
* steps onto it.
*/
export class Sign extends Decorator {
constructor(terrain, message) {
super(
terrain,
"Sign",
terrain.flags,
NONE,
Symbol.of(
"⌂",
WHITE,
terrain.symbol.getBackground(false),
BLACK,
terrain.symbol.getBackground(true),
),
);
this.message = message;
}
onEnterInternal(_event, _player, cell, _dir) {
events.fireMessage(cell, this.message);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, message]) {
return new Sign(_rt(terrain), message);
}
store(s) {
return `Sign|${this.esc(s.terrain)}|${s.message}`;
}
example() {
return new Sign(Registry.get("Floor"), "Hello!");
}
template(_id) {
return "Sign|{terrain}|{message}";
}
tag() {
return "Room Features";
}
})();
}
class Rubble extends Decorator {
constructor(terrain) {
super(
terrain,
"Rubble",
TRAVERSABLE | PENETRABLE,
null,
Symbol.of(
"∴",
BARELY_BUILDING_WALL,
terrain.symbol.getBackground(false),
BARELY_BUILDING_WALL,
terrain.symbol.getBackground(true),
),
);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]) {
return new Rubble(_rt(terrain));
}
example() {
return new Rubble(Registry.get("Floor"));
}
store(s) {
return `Rubble|${this.esc(s.terrain)}`;
}
template(_id) {
return "Rubble|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
// ── Simple color-event decorators ─────────────────────────────────────────────
/**
* Sets a flag on the player when a matching color event is received.
*
* Typical use: mark the player as having completed a task or entered a
* region — e.g. set the "poisoned" flag when the player steps on a
* trap that fires a color event.
*
* @extends Decorator
*/
export class Flagger extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers the flag change.
* @param {number} flag - Bitmask flag to add to the player.
*/
constructor(terrain, color, flag) {
super(terrain, null, 0, color, null);
this.flag = flag;
}
/**
* Adds {@link Flagger#flag} to the player when the received color matches
* {@link Flagger#color}. Skips silently when there is no player in the event.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(event, color, _cell) {
if (color === this.color && event.player) {
event.player.add(this.flag);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, flagStr]) {
return new Flagger(
_rt(terrain),
colorByName(color) ?? NONE,
getFlag(flagStr),
);
}
store(f) {
return `Flagger|${this.esc(f.terrain)}|${f.color.name}|${description([f.flag])}`;
}
example() {
return new Flagger(Registry.get("Floor"), NONE, "Poisoned");
}
template(_id) {
return "Flagger|{terrain}|{color}|{flag}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Clears a flag from the player when a matching color event is received.
*
* Typical use: cure a status effect or reset a condition — e.g. remove
* the "poisoned" flag when the player drinks from a fountain that fires
* a color event.
*
* @extends Decorator
*/
export class Unflagger extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers the flag change.
* @param {number} flag - Bitmask flag to remove from the player.
*/
constructor(terrain, color, flag) {
super(terrain, null, 0, color, null);
this.flag = flag;
}
/**
* Removes {@link Unflagger#flag} from the player when the received color
* matches {@link Unflagger#color}. Skips silently when there is no player
* in the event.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(event, color, _cell) {
if (color === this.color && event.player) {
event.player.remove(this.flag);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, flagStr]) {
return new Unflagger(
_rt(terrain),
colorByName(color) ?? NONE,
getFlag(flagStr),
);
}
store(f) {
return `Unflagger|${this.esc(f.terrain)}|${f.color.name}|${description([f.flag])}`;
}
example() {
return new Unflagger(Registry.get("Floor"), NONE, "Poisoned");
}
template(_id) {
return "Unflagger|{terrain}|{color}|{flag}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Displays a modal message to the player when a matching color event is
* received.
*
* Typical use: deliver narrative text or instructions at a scripted moment
* — e.g. show a warning when the player triggers a trap, or present lore
* when a lever is pulled elsewhere on the board.
*
* @extends Decorator
*/
export class Messenger extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers the message.
* @param {string} message - The text shown in the modal dialog.
*/
constructor(terrain, color, message) {
super(terrain, null, 0, color, null);
this.message = message;
}
/**
* Shows the modal message when the received color matches
* {@link Messenger#color}.
*
* @param {GameEvent} _event - Unused.
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(_event, color, _cell) {
if (color === this.color) {
events.fireModalMessage(this.message);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, message]) {
return new Messenger(_rt(terrain), colorByName(color) ?? NONE, message);
}
store(m) {
return `Messenger|${this.esc(m.terrain)}|${m.color.name}|${m.message}`;
}
example() {
return new Messenger(Registry.get("Floor"), NONE, "Hello!");
}
template(_id) {
return "Messenger|{terrain}|{color}|{message}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Adds a specific item to the player's bag when a matching color event is
* received.
*
* Typical use: grant the player an item as a reward or story beat — e.g.
* place a Key in the player's bag when a puzzle is solved elsewhere on the
* board.
*
* @extends Decorator
*/
export class Equipper extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers the item grant.
* @param {Item} item - The item instance to add to the player's bag.
*/
constructor(terrain, color, item) {
super(terrain, null, 0, color, null);
this.item = item;
}
/**
* Adds {@link Equipper#item} to the player's bag when the received color
* matches {@link Equipper#color}. Skips silently when there is no player
* in the event.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(event, color, _cell) {
if (color === this.color && event.player) event.player.bag.add(this.item);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, item]) {
return new Equipper(_rt(terrain), colorByName(color) ?? NONE, _rt(item));
}
store(e) {
return `Equipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
}
example() {
return null;
}
template(_id) {
return "Equipper|{terrain}|{color}|{item}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Removes a specific item from the player's bag when a matching color event
* is received.
*
* Typical use: consume a required item as part of a puzzle or trade — e.g.
* take the Chalice from the player when it is delivered to an altar that
* fires a color event.
*
* @extends Decorator
*/
export class Unequipper extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers the item removal.
* @param {Item} item - The item instance to remove from the player's bag.
*/
constructor(terrain, color, item) {
super(terrain, null, 0, color, null);
this.item = item;
}
/**
* Removes {@link Unequipper#item} from the player's bag when the received
* color matches {@link Unequipper#color}. Skips silently when there is no
* player in the event.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(event, color, _cell) {
if (color === this.color && event.player)
event.player.bag.remove(this.item);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, item]) {
return new Unequipper(
_rt(terrain),
colorByName(color) ?? NONE,
_rt(item),
);
}
store(e) {
return `Unequipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
}
example() {
return null;
}
template(_id) {
return "Unequipper|{terrain}|{color}|{item}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Re-broadcasts a different color event when it receives its own color event,
* acting as a color-channel multiplexer.
*
* Typical use: fan out one signal to multiple independent listeners on
* different color channels, or chain together sequences of color-triggered
* effects without wiring every cell to the same color.
*
* @extends Decorator
*/
export class ColorRelay extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The incoming color event that triggers the relay.
* @param {Color} relayTo - The outgoing color event broadcast to the board.
*/
constructor(terrain, color, relayTo) {
super(terrain, null, 0, color, null);
this.relayTo = relayTo;
}
/**
* Fires {@link ColorRelay#relayTo} on the board when the received color
* matches {@link ColorRelay#color}.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} cell
*/
onColorEventInternal(event, color, cell) {
if (color === this.color)
event.board.fireColorEvent(event, this.relayTo, cell);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, relayTo]) {
return new ColorRelay(
_rt(terrain),
colorByName(color) ?? NONE,
colorByName(relayTo) ?? NONE,
);
}
store(cr) {
return `ColorRelay|${this.esc(cr.terrain)}|${cr.color.name}|${cr.relayTo.name}`;
}
example() {
return new ColorRelay(Registry.get("Floor"), NONE, NONE);
}
template(_id) {
return "ColorRelay|{terrain}|{fromColor}|{toColor}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Ends the game with a victory screen when a matching color event is received.
*
* Typical use: trigger the win condition — e.g. place this on the final
* objective cell and fire its color event when the player delivers the last
* required item or solves the final puzzle.
*
* @extends Decorator
*/
export class EndGame extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers the game-over screen.
* @param {string} url - URL of the victory page passed to
* {@link game.gameOver}.
*/
constructor(terrain, color, url) {
super(terrain, null, 0, color, null);
this.url = url;
}
/**
* Calls {@link game.gameOver} with the victory URL when the received color
* matches {@link EndGame#color}.
*
* @param {GameEvent} event - Unused.
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(event, color, _cell) {
if (color === this.color) {
game.gameOver(this.url, true);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, url]) {
return new EndGame(_rt(terrain), colorByName(color) ?? NONE, url);
}
store(w) {
return `EndGame|${this.esc(w.terrain)}|${w.color.name}|${w.url}`;
}
example() {
return new EndGame(Registry.get("Floor"), NONE, "win.html");
}
template(_id) {
return "EndGame|{terrain}|{color}|{url}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Places an item or spawns an agent on the board when a matching color event
* is received. The piece is placed either at this decorator's cell or at the
* origin cell of the triggering event, depending on {@link PieceCreator#atOrigin}.
*
* Typical use: spawn a reward or enemy at a fixed location when a puzzle is
* solved elsewhere — e.g. place a Key on a pedestal or summon a guardian
* when the player pulls a lever on the other side of the board.
*
* @extends Decorator
*/
export class PieceCreator extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers piece placement.
* @param {Item|Agent} piece - The item or agent instance to place.
* @param {boolean} atOrigin - When true, place at the event's origin cell
* instead of this cell.
*/
constructor(terrain, color, piece, atOrigin) {
super(terrain, null, 0, color, null);
this.piece = piece;
this.atOrigin = atOrigin;
}
/**
* Places {@link PieceCreator#piece} on the target cell when the received
* color matches {@link PieceCreator#color}. Items are added unconditionally;
* agents are only placed if the target cell has no existing agent.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} cell
*/
onColorEventInternal(event, color, cell) {
if (color !== this.color) return;
// Place item or agent at this cell (or origin cell if atOrigin=true)
const target = this.atOrigin ? (event._originCell ?? cell) : cell;
if (this.piece?.isItem) target.addItem(this.piece);
else if (this.piece && target.agent == null) target.setAgent(this.piece);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, piece, atOrigin]) {
return new PieceCreator(
_rt(terrain),
colorByName(color) ?? NONE,
piece,
atOrigin === "true",
);
}
store(pc) {
const pieceKey =
typeof pc.piece === "string" ? pc.piece : this.esc(pc.piece);
return `PieceCreator|${this.esc(pc.terrain)}|${pc.color.name}|${pieceKey}|${pc.atOrigin}`;
}
example() {
return null;
}
template(_id) {
return "PieceCreator|{terrain}|{color}|{piece}|{atOrigin}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Destroys agents that interact with this cell, operating in one of two modes
* depending on whether a color is configured.
*
* - **Passive mode** (color is {@link NONE}): destroys any non-player agent
* the moment it steps onto this cell, removing it from the cell it entered
* from.
* - **Active mode** (color is set): destroys the agent currently on this cell
* when a matching color event is received.
*
* Typical use: create invisible kill-zones for agents, or trigger a boss
* defeat remotely via a color event.
*
* @extends Decorator
*/
export class AgentDestroyer extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event that triggers agent removal in
* active mode. Pass {@link NONE} for passive mode.
*/
constructor(terrain, color) {
super(terrain, null, 0, color, null);
}
/**
* In passive mode (color is {@link NONE}), removes the agent from the cell
* it entered from as soon as it steps onto this cell.
*
* @param {GameEvent} _event - Unused.
* @param {Agent} agent
* @param {Cell} cell
* @param {Direction} dir
*/
onAgentEnterInternal(_event, agent, cell, dir) {
if (!this.color || this.color === NONE) {
cell.getAdjacentCell(dir.reverse)?.removeAgent(agent);
}
}
/**
* In active mode, removes the agent on this cell when the received color
* matches {@link AgentDestroyer#color}. Skips silently when the cell has
* no agent.
*
* @param {GameEvent} event - Unused.
* @param {Color} color
* @param {Cell} cell
*/
onColorEventInternal(event, color, cell) {
if (color === this.color && cell.agent) {
cell.removeAgent(cell.agent);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color]) {
return new AgentDestroyer(_rt(terrain), colorByName(color) ?? NONE);
}
store(ad) {
return `AgentDestroyer|${this.esc(ad.terrain)}|${ad.color.name}`;
}
example() {
return new AgentDestroyer(Registry.get("Floor"), NONE);
}
template(_id) {
return "AgentDestroyer|{terrain}|{color}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Blocks the player from entering this cell unless they possess a specific
* flag or item. An optional modal message is shown when movement is blocked,
* matching the conversational feel of an NPC gate.
*
* Typical use: pair with an NPC to simulate a guard who refuses to let the
* player pass until a condition is met — e.g. require the player to carry
* a Pass before crossing a checkpoint.
*
* @extends Decorator
*/
export class PlayerGate extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {string} testable - Flag name or item name the player must
* possess to pass.
* @param {string|null} message - Optional modal message shown when the
* player is blocked.
*/
constructor(terrain, testable, message) {
super(terrain, null, 0, NONE, null);
this.testable = testable;
this.message = message ?? null;
}
/**
* Cancels the movement event if the player does not satisfy
* {@link PlayerGate#testable}, optionally showing a modal message first.
* Skips silently when the event is already cancelled.
*
* @override
* @param {GameEvent} event
* @param {Player} player
* @param {Cell} cell
* @param {Direction} _dir - Unused.
*/
onEnterInternal(event, player, cell, _dir) {
if (event.isCancelled) {
return;
}
if (player.matchesFlagOrItem(this.testable)) {
if (this.message) {
events.fireModalMessage(this.message);
}
event.cancel();
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
return new PlayerGate(_rt(args[0]), args[1], args[2] ?? null);
}
store(pg) {
return pg.message
? `PlayerGate|${this.esc(pg.terrain)}|${pg.flag}|${pg.message}`
: `PlayerGate|${this.esc(pg.terrain)}|${pg.flag}`;
}
example() {
return new PlayerGate(Registry.get("Floor"), "Poisoned", null);
}
template(_id) {
return "PlayerGate|{terrain}|{testable}|{message?}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Restricts cell traversal to the player only — all non-player agents are
* prevented from both entering and exiting.
*
* Typical use: confine enemies to a region or corridor without affecting
* player movement — e.g. line the border of an arena so its inhabitants
* cannot wander out.
*
* @extends Decorator
*/
export class AgentGate extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
*/
constructor(terrain) {
super(terrain, null, 0, NONE, null);
}
/**
* Allows entry only for the player; delegates to the wrapped terrain for
* the player, and returns false for all other agents.
*
* @override
* @param {Agent} agent
* @param {Cell} cell
* @param {Direction} direction
* @returns {boolean}
*/
canEnter(agent, cell, direction) {
if (is(PLAYER, agent.flags)) {
return this.terrain.canEnter(agent, cell, direction);
}
return false;
}
/**
* Allows exit only for the player; delegates to the wrapped terrain for
* the player, and returns false for all other agents.
*
* @override
* @param {Agent} agent
* @param {Cell} cell
* @param {Direction} direction
* @returns {boolean}
*/
canExit(agent, cell, direction) {
if (is(PLAYER, agent.flags)) {
return this.terrain.canExit(agent, cell, direction);
}
return false;
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]) {
return new AgentGate(_rt(terrain));
}
store(ag) {
return `AgentGate|${this.esc(ag.terrain)}`;
}
example() {
return new AgentGate(Registry.get("Floor"));
}
template(_id) {
return "AgentGate|{terrain}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Displays the symbol of one terrain while behaving like another, creating
* a hidden terrain effect. When a player with the {@link DETECT_HIDDEN} flag
* is adjacent, the cell briefly reveals the actual terrain's symbol.
*
* Typical use: disguise a dangerous or traversable cell as something else
* — subclasses {@link SecretPassage} and {@link PitTrap} are the canonical
* examples.
*
* @extends Decorator
*/
export class Mimic extends Decorator {
/**
* @param {Terrain} appearsAs - The terrain whose symbol is shown to the player.
* @param {Terrain} actual - The terrain that governs behavior and traversal.
* @param {Color} color - Color used by the underlying {@link Decorator}.
*/
constructor(appearsAs, actual, color) {
super(actual, null, 0, color, appearsAs.symbol);
this.appearsAs = appearsAs;
}
/** Returns the behavioral terrain (the one being wrapped). */
getProxiedTerrain() {
return this.terrain;
}
/** Returns the visual terrain (the one whose symbol is displayed). */
getApparentTerrain() {
return this.appearsAs;
}
/**
* When the player enters an adjacent cell and has {@link DETECT_HIDDEN},
* swaps the cell's animation symbol to reveal the actual terrain.
*
* @override
* @param {GameEvent} event
* @param {Cell} cell
*/
onAdjacentToInternal(event, cell) {
if (event.player.is(DETECT_HIDDEN)) {
cell._animTerrainSymbol = this.terrain.symbol;
cell.board.notifyCellChange(cell);
}
}
/**
* When the player leaves the adjacent position, restores the cell's
* animation symbol so the disguise is shown again.
*
* @override
* @param {GameEvent} _event - Unused.
* @param {Cell} cell
*/
onNotAdjacentToInternal(_event, cell) {
cell._animTerrainSymbol = null;
cell.board.notifyCellChange(cell);
}
static SERIALIZER = new (class extends BaseSerializer {
create([appearsAs, actual, color]) {
return new Mimic(_rt(appearsAs), _rt(actual), colorByName(color) ?? NONE);
}
store(m) {
return `Mimic|${this.esc(m.appearsAs)}|${this.esc(m.terrain)}|${m.color.name}`;
}
example() {
return null;
}
template(_id) {
return "Mimic|{appearsAsTerrain}|{actualTerrain}|{color}";
}
tag() {
return "Utility Terrain";
}
})();
}
/** SecretPassage — looks like Wall but is traversable like Floor. */
export class SecretPassage extends Mimic {
constructor(wall, floor) {
super(wall, floor, NONE);
}
canEnter(agent, cell, direction) {
if (is(PLAYER, agent.flags))
return this.terrain.canEnter(agent, cell, direction);
return false;
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("SecretPassage");
}
create(_args) {
return new SecretPassage(Registry.get("Wall"), Registry.get("Floor"));
}
tag() {
return "Terrain";
}
})();
}
/**
* Looks like {@link Floor} but drops the player into a {@link Pit} when
* stepped on. Boulders fill the pit instead of falling; {@link Slider} and
* {@link Pusher} agents fall through and are removed. Regular agents walk
* over hidden pits unaffected.
*
* Players with {@link DETECT_HIDDEN} see the pit symbol while standing
* adjacent, then the disguise is restored when they move away.
*
* @extends Mimic
*/
export class PitTrap extends Mimic {
constructor() {
super(Registry.get("Floor"), Registry.get("Pit"), NONE);
}
// PitTrap looks and behaves like Floor for entry purposes. canEnter must
// return true so the JS movement loop lets the player (and boulders) onto
// the cell; the actual trap effect is handled in onEnter/onAgentEnter.
canEnter(agent, cell, direction) {
return this.appearsAs.canEnter(agent, cell, direction);
}
// Override onEnter directly (not onEnterInternal) so that Pit's onEnter
// never runs — otherwise the Decorator base would call Pit.onEnter first,
// which cancels the event with "You'd fall into the pit".
onEnter(_event, player, cell, _dir) {
events.fireMessage(cell, "You fall into a pit!");
player.changeHealth(20);
// Schedule terrain reveal and fall-through teleport after a brief delay,
// matching Java's Timer.schedule(200) pattern.
setTimeout(() => {
cell.setTerrain(this.terrain); // reveal the pit
if (player.health > 0) {
events.fireFallThrough(cell.x, cell.y);
}
}, 200);
// Do not cancel — player moves onto the cell, then gets teleported.
}
// Override onAgentEnter directly so that Pit's onAgentEnter never runs.
// Java PitTrap.onAgentEnter also overrides directly; the Java Javadoc notes
// "Yes, agents walk right over these things" for non-boulder agents.
onAgentEnter(_event, agent, cell, dir) {
if (agent instanceof AbstractBoulder) {
const prevCell = cell.getAdjacentCell(dir.reverse);
prevCell?.removeAgent(agent);
cell.setTerrain(Registry.get("Floor"));
events.fireMessage(cell, "The boulder fills a hidden pit!");
} else if (agent instanceof Slider || agent instanceof Pusher) {
const prevCell = cell.getAdjacentCell(dir.reverse);
prevCell?.removeAgent(agent);
cell.setTerrain(this.terrain); // reveal the pit
events.fireMessage(
cell,
`The ${agent.name.toLowerCase()} falls through a hidden pit`,
);
}
// Regular agents walk right over hidden pits (no cancel, no action).
}
onAdjacentTo(event, cell) {
if (event.player.is(DETECT_HIDDEN)) {
cell._animTerrainSymbol = Registry.get("Pit").symbol;
cell.board.notifyCellChange(cell);
}
}
onNotAdjacentTo(_event, cell) {
cell._animTerrainSymbol = null;
cell.board.notifyCellChange(cell);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("PitTrap");
}
create(_args) {
return new PitTrap();
}
tag() {
return "Room Features";
}
})();
}
// ── Trapped containers ────────────────────────────────────────────────────────
/** Base for containers that release a cloud when opened. */
class TrapContainerBase extends Decorator {
constructor(terrain, cloudType) {
super(terrain, null, 0, NONE, null);
this.cloudType = cloudType;
}
onEnterInternal(event, player, cell, _dir) {
if (event.isCancelled) {
return;
}
// Spawn cloud effect
const cloudKey = this.cloudType;
try {
const cloud = Registry.get(cloudKey);
cell.addEffect(cloud);
} catch (_) {
/* cloud not yet registered */
}
}
}
/**
* Releases an {@link EnergyCloud} effect onto the cell when the player
* enters, simulating a trap hidden inside a container.
*
* @extends TrapContainerBase
*/
export class EnergyTrapContainer extends TrapContainerBase {
constructor(terrain) {
super(terrain, "EnergyCloud");
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]) {
return new EnergyTrapContainer(_rt(terrain));
}
store(tc) {
return `EnergyTrapContainer|${this.esc(tc.terrain)}`;
}
example() {
return new EnergyTrapContainer(Registry.get("Floor"));
}
template(_id) {
return "EnergyTrapContainer|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Releases a {@link PoisonCloud} effect onto the cell when the player
* enters, simulating a trap hidden inside a container.
*
* @extends TrapContainerBase
*/
export class PoisonTrapContainer extends TrapContainerBase {
constructor(terrain) {
super(terrain, "PoisonCloud");
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]) {
return new PoisonTrapContainer(_rt(terrain));
}
store(tc) {
return `PoisonTrapContainer|${this.esc(tc.terrain)}`;
}
example() {
return new PoisonTrapContainer(Registry.get("Floor"));
}
template(_id) {
return "PoisonTrapContainer|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Releases a {@link ResistancesCloud} effect onto the cell when the player
* enters, simulating a trap hidden inside a container.
*
* @extends TrapContainerBase
*/
export class ResistancesTrapContainer extends TrapContainerBase {
constructor(terrain) {
super(terrain, "ResistancesCloud");
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]) {
return new ResistancesTrapContainer(_rt(terrain));
}
store(tc) {
return `ResistancesTrapContainer|${this.esc(tc.terrain)}`;
}
example() {
return new ResistancesTrapContainer(Registry.get("Floor"));
}
template(_id) {
return "ResistancesTrapContainer|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
// ── Cliff ─────────────────────────────────────────────────────────────────────
/**
* Cliff — decorator that enforces directional cliff traversal.
* Agents can only enter a Cliff cell from a non-cliff side, and only exit
* to a non-cliff side. The player gets a "too steep" message otherwise.
*/
export class Cliff extends Decorator {
constructor(terrain) {
super(terrain, null, 0, NONE, null);
}
proxy(terrain) {
return new Cliff(terrain);
}
canEnter(agent, cell, dir) {
if (!super.canEnter(agent, cell, dir)) {
return false;
}
const behind = cell.getAdjacentCell?.(dir?.reverse);
if (!behind) {
return true;
}
return behind.getApparentTerrain().name === this.terrain.name;
}
canExit(agent, cell, dir) {
if (!super.canExit(agent, cell, dir)) {
return false;
}
const ahead = cell.getAdjacentCell(dir);
if (!ahead) {
return true;
}
return ahead.getApparentTerrain().name === this.terrain.name;
}
onEnterInternal(event, _player, cell, dir) {
const behind = cell.getAdjacentCell(dir.reverse);
if (behind.getApparentTerrain().name !== this.terrain.name) {
events.fireMessage(cell, "It's too steep to climb up here.");
event.cancel();
}
}
onExitInternal(event, _player, cell, dir) {
const ahead = cell.getAdjacentCell?.(dir);
if (ahead.getApparentTerrain().name !== this.terrain.name) {
events.fireMessage(cell, "It's too steep to climb down here.");
event.cancel();
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]) {
return new Cliff(_rt(terrain));
}
store(c) {
return `Cliff|${this.esc(c.terrain)}`;
}
example() {
return new Cliff(Registry.get("Floor"));
}
template(_id) {
return "Cliff|{terrain}";
}
tag() {
return "Outside Terrain";
}
})();
}
// ── Timer ─────────────────────────────────────────────────────────────────────
/**
* Fires a color event on the board at a fixed interval, acting as a
* clock signal for other color-event-driven decorators.
*
* Typical use: drive periodic effects such as toggling a {@link DualTerrain}
* or pulsing a {@link ColorRelay} — e.g. open and close a gate every
* 20 frames, or spawn an agent at regular intervals.
*
* @extends Decorator
*/
export class Timer extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event broadcast on each tick.
* @param {number} frames - Interval in animation frames between firings.
* Values ≤ 0 are clamped to 1.
*/
constructor(terrain, color, frames) {
super(terrain, null, 0, color, null);
this._frames = frames > 0 ? frames : 1;
}
/** Returns a new {@link Timer} wrapping the given terrain, preserving color and interval. */
proxy(terrain) {
return new Timer(terrain, this.color, this._frames);
}
/**
* Called by the {@link AnimationManager} on each animation tick. Fires
* the color event on the board whenever the frame counter is a positive
* multiple of {@link Timer#_frames}.
*
* @param {CanvasRenderingContext2D} _ctx - Unused.
* @param {Cell} cell
* @param {number} frame - Current animation frame counter.
*/
onFrame(_ctx, cell, frame) {
if (frame > 0 && frame % this._frames === 0) {
cell.board.fireColorEvent(null, this.color, cell);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args) {
return new Timer(
_rt(args[0]),
colorByName(args[1]) ?? NONE,
parseInt(args[2]) || 1,
);
}
store(t) {
return `Timer|${this.esc(t.terrain)}|${t.color.name}|${t._frames}`;
}
example() {
return new Timer(Registry.get("Floor"), NONE, 10);
}
template(_id) {
return "Timer|{terrain}|{color}|{frames}";
}
tag() {
return "Utility Terrain";
}
})();
}
export function registerDecorators() {
Registry.register("Sign", Sign.SERIALIZER);
Registry.register("Rubble", Rubble.SERIALIZER);
Registry.register("DualTerrain", DualTerrain.SERIALIZER);
Registry.register("Flagger", Flagger.SERIALIZER);
Registry.register("Unflagger", Unflagger.SERIALIZER);
Registry.register("Messenger", Messenger.SERIALIZER);
Registry.register("Equipper", Equipper.SERIALIZER);
Registry.register("Unequipper", Unequipper.SERIALIZER);
Registry.register("ColorRelay", ColorRelay.SERIALIZER);
Registry.register("EndGame", EndGame.SERIALIZER);
Registry.register("PieceCreator", PieceCreator.SERIALIZER);
Registry.register("AgentDestroyer", AgentDestroyer.SERIALIZER);
Registry.register("PlayerGate", PlayerGate.SERIALIZER);
Registry.register("AgentGate", AgentGate.SERIALIZER);
Registry.register("Mimic", Mimic.SERIALIZER);
Registry.register("SecretPassage", SecretPassage.SERIALIZER);
Registry.register("PitTrap", PitTrap.SERIALIZER);
Registry.register("EnergyTrapContainer", EnergyTrapContainer.SERIALIZER);
Registry.register("PoisonTrapContainer", PoisonTrapContainer.SERIALIZER);
Registry.register(
"ResistancesTrapContainer",
ResistancesTrapContainer.SERIALIZER,
);
Registry.register("Cliff", Cliff.SERIALIZER);
Registry.register("Timer", Timer.SERIALIZER);
// TODO: There is no AgentCreator anywhere in the game, this is another
// hallucinated piece I think. PieceCreator does exist.
// AgentCreator|{terrain}|{color}|{agentKey}
// Creates the named agent at this cell on color event.
Registry.register("AgentCreator", (args) => {
const terrain = _rt(args[0]);
const color = colorByName(args[1]) ?? NONE;
const agent = typeof args[2] === "string" ? _tryGetPiece(args[2]) : args[2];
return new PieceCreator(terrain, color, agent, false);
});
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Resolve a terrain arg that may be a plain string key or an already-resolved Piece. */
function _rt(arg) {
return typeof arg === "string" ? Registry.get(arg) : arg;
}
/** Look up a flag bitmask by its display label, returning 0 if unknown. */
function getFlagByLabel(label) {
const v = getFlag(label);
return v === -1 ? 0 : v;
}
/** Try to get a piece from the registry; returns null if not found. */
function _tryGetPiece(key) {
try {
return Registry.get(key);
} catch (_) {
return null;
}
}