import { AbstractBoulder } 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,
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 { 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 ─────────────────────────────────────────────
/** Adds a flag to the player on color event. */
export class Flagger extends Decorator {
constructor(terrain, color, flagStr) {
super(terrain, null, 0, color, null);
this.flagStr = flagStr;
}
onColorEventInternal(event, color, _cell) {
if (color === this.color) {
const flag = getFlagByLabel(this.flagStr);
if (flag && event.player) event.player.flags |= flag;
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, flagStr]) {
return new Flagger(_rt(terrain), colorByName(color) ?? NONE, flagStr);
}
store(f) {
return `Flagger|${this.esc(f.terrain)}|${f.color.name}|${f.flagStr}`;
}
example() {
return new Flagger(Registry.get("Floor"), NONE, "poisoned");
}
template(_id) {
return "Flagger|{terrain}|{color}|{item}";
}
tag() {
return "Utility Terrain";
}
})();
}
/** Removes a flag from the player on color event. */
export class Unflagger extends Decorator {
constructor(terrain, color, flagStr) {
super(terrain, null, 0, color, null);
this.flagStr = flagStr;
}
onColorEventInternal(event, color, _cell) {
if (color === this.color) {
const flag = getFlagByLabel(this.flagStr);
if (flag && event.player) event.player.flags &= ~flag;
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, flagStr]) {
return new Unflagger(_rt(terrain), colorByName(color) ?? NONE, flagStr);
}
store(f) {
return `Unflagger|${this.esc(f.terrain)}|${f.color.name}|${f.flagStr}`;
}
example() {
return new Unflagger(Registry.get("Floor"), NONE, "poisoned");
}
template(_id) {
return "Unflagger|{terrain}|{color}|{item}";
}
tag() {
return "Utility Terrain";
}
})();
}
/** Shows a modal message on color event. */
export class Messenger extends Decorator {
constructor(terrain, color, message) {
super(terrain, null, 0, color, null);
this.message = message;
}
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 an item to the player's bag on color event. */
export class Equipper extends Decorator {
constructor(terrain, color, item) {
super(terrain, null, 0, color, null);
this.item = item;
}
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 an item from the player's bag on color event. */
export class Unequipper extends Decorator {
constructor(terrain, color, item) {
super(terrain, null, 0, color, null);
this.item = item;
}
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";
}
})();
}
/** Fires a different color event when it receives its own color event (multiplexing). */
export class ColorRelay extends Decorator {
constructor(terrain, color, relayTo) {
super(terrain, null, 0, color, null);
this.relayTo = relayTo;
}
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 URL when it receives its color event. */
export class WinGame extends Decorator {
constructor(terrain, color, url) {
super(terrain, null, 0, color, null);
this.url = url;
}
onColorEventInternal(event, color, _cell) {
if (color === this.color) {
events.fireModalMessage(`You win! ${this.url}`);
// In a full implementation this would navigate to the win screen.
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, url]) {
return new WinGame(_rt(terrain), colorByName(color) ?? NONE, url);
}
store(w) {
return `WinGame|${this.esc(w.terrain)}|${w.color.name}|${w.url}`;
}
example() {
return new WinGame(Registry.get("Floor"), NONE, "win.html");
}
template(_id) {
return "WinGame|{terrain}|{color}|{url}";
}
tag() {
return "Utility Terrain";
}
})();
}
/** Creates a piece (item or agent) at the decorator cell or origin on color event. */
export class PieceCreator extends Decorator {
constructor(terrain, color, piece, atOrigin) {
super(terrain, null, 0, color, null);
this.piece = piece;
this.atOrigin = atOrigin;
}
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 any agent that enters, or destroys agents on color event. */
export class AgentDestroyer extends Decorator {
constructor(terrain, color) {
super(terrain, null, 0, color, null);
}
onAgentEnterInternal(_event, agent, cell, _dir) {
if (!this.color || this.color === NONE) {
cell.removeAgent(agent);
}
}
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 players from entering unless they have a specific flag or item. */
export class PlayerGate extends Decorator {
constructor(terrain, flagStr, message) {
super(terrain, null, 0, NONE, null);
this.flagStr = flagStr;
this.message = message ?? null;
}
onEnterInternal(event, player, cell, _dir) {
if (event.isCancelled) return;
const hasFlag = checkPlayerHas(player, this.flagStr);
if (!hasFlag) {
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.flagStr}|${pg.message}`
: `PlayerGate|${this.esc(pg.terrain)}|${pg.flagStr}`;
}
example() {
return new PlayerGate(Registry.get("Floor"), "poisoned", null);
}
template(_id) {
return "PlayerGate|{terrain}|{flag}|{message?}";
}
tag() {
return "Utility Terrain";
}
})();
}
/** Blocks all non-player agents from entering or exiting. */
export class AgentGate extends Decorator {
constructor(terrain) {
super(terrain, null, 0, NONE, null);
}
canEnter(agent, cell, direction) {
if (is(PLAYER, agent.flags))
return this.terrain.canEnter(agent, cell, direction);
return false;
}
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";
}
})();
}
/** Mimic — appears as one terrain but behaves as another. */
export class Mimic extends Decorator {
/**
* @param {Terrain} appearsAs - visual terrain
* @param {Terrain} actual - behavioral terrain
* @param {string} color - trigger color
*/
constructor(appearsAs, actual, color) {
super(actual, null, 0, color, appearsAs.symbol);
this.appearsAs = appearsAs;
}
getProxiedTerrain() {
return this.terrain;
}
getApparentTerrain() {
return this.appearsAs;
}
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";
}
})();
}
/** PitTrap — looks like Floor but drops the player into a Pit. */
export class PitTrap extends Mimic {
constructor(floor, pit) {
super(floor, 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.isSlider || agent.isPusher) {
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(Registry.get("Floor"), Registry.get("Pit"));
}
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 */
}
}
}
/** Energy trap container */
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";
}
})();
}
/** Poison trap container */
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";
}
})();
}
/** Resistances trap container */
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 ─────────────────────────────────────────────────────────────────────
/**
* Timer — decorator that fires a color event every N frames.
* The color event is broadcast to the board.
*/
export class Timer extends Decorator {
constructor(terrain, color, frames) {
super(terrain, null, 0, color, null);
this._frames = frames > 0 ? frames : 1;
}
proxy(terrain) {
return new Timer(terrain, this.color, this._frames);
}
// onFrame is called by the AnimationManager each tick
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("WinGame", WinGame.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);
// 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 ───────────────────────────────────────────────────────────────────
/** Check if a player has a given flag (by label) or item (by name). */
function checkPlayerHas(player, flagStr) {
if (!player || !flagStr) {
return false;
}
const flag = getFlag(flagStr);
if (flag !== -1 && player.is(flag)) {
return true;
}
return player.bag.find((i) => i.name === flagStr) != null;
}
/** 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;
}
}