1
0
Fork 0

Anonymous components

This commit is contained in:
Pabloader 2026-05-02 09:00:27 +00:00
parent a340c50f57
commit 762feba096
24 changed files with 832 additions and 290 deletions

View File

@ -3,13 +3,8 @@ import { randInt } from "@common/utils";
import { createCanvas } from './canvas'; import { createCanvas } from './canvas';
export type IColorLike = string | number | Color; export type IColorLike = string | number | Color;
export type IChar = [string, IColorLike?, IColorLike?]; export type IChar = [string, IColorLike?, IColorLike?] | string;
export type IRegion = IChar[][]; export type IDefinedChar = [string, IColorLike, IColorLike];
export interface ISpriteDefinition {
frames: IRegion[];
animationPeriod?: number;
}
export const randChar = (min = ' ', max = '~') => export const randChar = (min = ' ', max = '~') =>
String.fromCharCode(randInt( String.fromCharCode(randInt(
@ -56,6 +51,9 @@ export enum Color {
WHITE = 0b1111, WHITE = 0b1111,
} }
export const DEFAULT_FG = Color.WHITE;
export const DEFAULT_BG = Color.BLACK;
export enum Direction { export enum Direction {
NORTH = 0, NORTH = 0,
EAST = 1, EAST = 1,
@ -86,10 +84,21 @@ const CHAR_W = 8;
const CHAR_H = 16; const CHAR_H = 16;
const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`; const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`;
const FALLBACK_FONT = `${CHAR_H}px monospace`; const FALLBACK_FONT = `${CHAR_H}px monospace`;
const REGION_DATA = Symbol('TextRegion.data');
const colorToCSS = (c: IColorLike): string => const colorToCSS = (c: IColorLike): string =>
typeof c === 'number' ? COLORS[c] : c as string; typeof c === 'number' ? COLORS[c] : c as string;
const parseChar = (char: IChar): IDefinedChar => (
typeof char === 'string'
? [char, DEFAULT_FG, DEFAULT_BG]
: [
char[0],
char[1] ?? DEFAULT_FG,
char[2] ?? DEFAULT_BG,
]
);
export class TextDisplay { export class TextDisplay {
private chars: string[]; private chars: string[];
private fgs: IColorLike[]; private fgs: IColorLike[];
@ -103,7 +112,7 @@ export class TextDisplay {
public width = GAME_WIDTH, public width = GAME_WIDTH,
public height = GAME_HEIGHT, public height = GAME_HEIGHT,
parent?: HTMLCanvasElement, parent?: HTMLCanvasElement,
letterboxColor: IColorLike = Color.BLACK, letterboxColor: IColorLike = DEFAULT_BG,
) { ) {
this.letterboxColor = colorToCSS(letterboxColor); this.letterboxColor = colorToCSS(letterboxColor);
const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H); const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H);
@ -115,9 +124,9 @@ export class TextDisplay {
this.ctx.textBaseline = 'top'; this.ctx.textBaseline = 'top';
const size = width * height; const size = width * height;
this.chars = Array.from({ length: size }, () => randChar('!')); this.chars = new Array(size).fill(' ');
this.fgs = new Array(size).fill(COLORS[7]); this.fgs = new Array(size).fill(DEFAULT_FG);
this.bgs = new Array(size).fill(COLORS[0]); this.bgs = new Array(size).fill(DEFAULT_BG);
window.addEventListener('resize', () => this.updateScale()); window.addEventListener('resize', () => this.updateScale());
this.updateScale(); this.updateScale();
@ -173,47 +182,97 @@ export class TextDisplay {
private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) { private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) {
if (x < 0 || y < 0 || y >= this.height || x >= this.width || !char) return; if (x < 0 || y < 0 || y >= this.height || x >= this.width || !char) return;
const i = (y | 0) * this.width + (x | 0); const i = (y | 0) * this.width + (x | 0);
if (this.chars[i] === char && this.fgs[i] === fg && this.bgs[i] === bg) return; let dirty = false;
this.chars[i] = char; if (this.chars[i] !== char) {
this.fgs[i] = fg; this.chars[i] = char;
this.bgs[i] = bg; dirty = true;
this.drawCell(x | 0, y | 0); }
if (this.fgs[i] !== fg) {
this.fgs[i] = fg;
dirty = true;
}
if (this.bgs[i] !== bg) {
this.bgs[i] = bg;
dirty = true;
}
if (dirty) {
this.drawCell(x | 0, y | 0);
}
} }
setChar(x: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['█']) { setChar(x: number, y: number, char: IChar = '█') {
this.setCharRaw(x, y, char, fg, bg); this.setCharRaw(x | 0, y | 0, ...parseChar(char));
} }
getChar(x: number, y: number): IChar { getChar(x: number, y: number): IDefinedChar {
x = x | 0; y = y | 0;
if (x < 0 || y < 0 || y >= this.height || x >= this.width) { if (x < 0 || y < 0 || y >= this.height || x >= this.width) {
return [' ', COLORS[0], COLORS[1]]; return [' ', DEFAULT_FG, DEFAULT_BG];
} }
const i = (y | 0) * this.width + (x | 0); return [this.chars[y * this.width + x], this.fgs[y * this.width + x], this.bgs[y * this.width + x]];
return [this.chars[i], this.fgs[i], this.bgs[i]];
} }
setRegion(x: number, y: number, w: number, h: number, region: IRegion) { setRegion(x: number, y: number, region: TextRegion) {
for (let row = 0; row < h; row++) { x = x | 0;
for (let col = 0; col < w; col++) { y = y | 0;
const [char, fg = 'white', bg = 'black'] = region[row][col]; const { chars, fgs, bgs } = region[REGION_DATA];
this.setCharRaw(x + col, y + row, char, fg, bg); const rw = region.width;
const x0 = Math.max(0, x);
const y0 = Math.max(0, y);
const x1 = Math.min(this.width, x + rw);
const y1 = Math.min(this.height, y + region.height);
const copyW = x1 - x0;
if (copyW <= 0) return;
const regColOffset = x0 - x;
for (let row = y0; row < y1; row++) {
const regRow = row - y;
const regBase = regRow * rw + regColOffset;
const dispBase = row * this.width + x0;
const regRowStr = chars[regRow];
for (let i = 0; i < copyW; i++) {
this.chars[dispBase + i] = regRowStr[regColOffset + i];
this.fgs[dispBase + i] = fgs[regBase + i];
this.bgs[dispBase + i] = bgs[regBase + i];
this.drawCell(x0 + i, row);
} }
} }
} }
getRegion(x: number, y: number, w: number, h: number) { getRegion(x: number, y: number, w: number, h: number): TextRegion {
const region: IRegion = []; x = x | 0; y = y | 0; w = w | 0; h = h | 0;
const rows: string[] = [];
const fgs: IColorLike[][] = [];
const bgs: IColorLike[][] = [];
for (let row = 0; row < h; row++) { for (let row = 0; row < h; row++) {
const line: IChar[] = []; const dispRow = y + row;
let rowStr = '';
const fgRow: IColorLike[] = [];
const bgRow: IColorLike[] = [];
for (let col = 0; col < w; col++) { for (let col = 0; col < w; col++) {
line.push(this.getChar(x + col, y + row)); const dispCol = x + col;
if (dispRow < 0 || dispRow >= this.height || dispCol < 0 || dispCol >= this.width) {
rowStr += ' ';
fgRow.push(DEFAULT_FG);
bgRow.push(DEFAULT_BG);
} else {
const i = dispRow * this.width + dispCol;
rowStr += this.chars[i];
fgRow.push(this.fgs[i]);
bgRow.push(this.bgs[i]);
}
} }
region.push(line); rows.push(rowStr);
fgs.push(fgRow);
bgs.push(bgRow);
} }
return region;
return new TextRegion(rows, fgs, bgs);
} }
drawText(x: number, y: number, text: string, fg: IColorLike = Color.WHITE, bg: IColorLike = Color.BLACK) { drawText(x: number, y: number, text: string, fg: IColorLike = DEFAULT_FG, bg: IColorLike = DEFAULT_BG) {
x = x | 0; y = y | 0;
const lines = text.split('\n'); const lines = text.split('\n');
for (let row = 0; row < lines.length; row++) { for (let row = 0; row < lines.length; row++) {
const line = lines[row]; const line = lines[row];
@ -225,27 +284,35 @@ export class TextDisplay {
} }
} }
drawVLine(x: number, y1: number, y2: number, [char, fg = 'white', bg = 'black']: IChar = ['│']) { drawVLine(x: number, y1: number, y2: number, char: IChar = '│') {
x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; } if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
y1 = Math.max(0, y1); y1 = Math.max(0, y1);
y2 = Math.min(this.height - 1, y2); y2 = Math.min(this.height - 1, y2);
if (x < 0 || x >= this.width) return; if (x < 0 || x >= this.width) return;
const ch = parseChar(char);
for (let y = y1; y <= y2; y++) { for (let y = y1; y <= y2; y++) {
this.setCharRaw(x, y, char, fg, bg); this.setCharRaw(x, y, ...ch);
} }
} }
drawHLine(x1: number, x2: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['─']) { drawHLine(x1: number, x2: number, y: number, char: IChar = '─') {
x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; } if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
x1 = Math.max(0, x1); x1 = Math.max(0, x1);
x2 = Math.min(this.width - 1, x2); x2 = Math.min(this.width - 1, x2);
if (y < 0 || y >= this.height) return; if (y < 0 || y >= this.height) return;
const ch = parseChar(char);
for (let x = x1; x <= x2; x++) { for (let x = x1; x <= x2; x++) {
this.setCharRaw(x, y, char, fg, bg); this.setCharRaw(x, y, ...ch);
} }
} }
drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) { drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) {
x = x | 0; y = y | 0;
const { const {
vertical = '│', vertical = '│',
horizontal = '─', horizontal = '─',
@ -253,8 +320,8 @@ export class TextDisplay {
topRight = '┐', topRight = '┐',
bottomLeft = '└', bottomLeft = '└',
bottomRight = '┘', bottomRight = '┘',
fg = Color.WHITE, fg = DEFAULT_FG,
bg = Color.BLACK, bg = DEFAULT_BG,
fill, fill,
title, title,
} = options; } = options;
@ -280,9 +347,10 @@ export class TextDisplay {
} }
drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) { drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) {
x = x | 0; y = y | 0;
const { const {
fg = Color.WHITE, fg = DEFAULT_FG,
bg = Color.BLACK, bg = DEFAULT_BG,
} = options; } = options;
let width = 0; let width = 0;
const lines = text.split('\n'); const lines = text.split('\n');
@ -296,10 +364,75 @@ export class TextDisplay {
this.drawText(x + 1, y + 1, text, fg, bg); this.drawText(x + 1, y + 1, text, fg, bg);
} }
fillBox(x: number, y: number, width: number, height: number, char: IChar = ['█']) { fillBox(x: number, y: number, width: number, height: number, char: IChar = '█') {
x = x | 0; y = y | 0;
for (let i = y; i < y + height; i++) { for (let i = y; i < y + height; i++) {
this.drawHLine(x, x + width - 1, i, char); this.drawHLine(x, x + width - 1, i, char);
} }
} }
} }
export class TextRegion {
readonly #chars: string[]; // one string per row, all same length
readonly #fgs: IColorLike[];
readonly #bgs: IColorLike[];
get width(): number { return this.#chars[0]?.length ?? 0; }
get height(): number { return this.#chars.length; }
constructor(chars: IChar[][]);
constructor(chars: string, fg?: IColorLike | IColorLike[], bg?: IColorLike | IColorLike[]);
constructor(chars: string[], fg?: IColorLike | IColorLike[][], bg?: IColorLike | IColorLike[][]);
constructor(
chars: IChar[][] | string | string[],
fg?: IColorLike | IColorLike[] | IColorLike[][],
bg?: IColorLike | IColorLike[] | IColorLike[][],
) {
if (typeof chars === 'string') {
chars = chars.split('\n');
}
if (chars.length === 0 || typeof chars[0] === 'string') {
const rows = chars as string[];
const w = rows.reduce((m, r) => Math.max(m, r.length), 0);
const h = rows.length;
this.#chars = rows.map(r => r.padEnd(w, ' '));
this.#fgs = Array(w * h).fill(DEFAULT_FG);
this.#bgs = Array(w * h).fill(DEFAULT_BG);
if (fg != null && !Array.isArray(fg)) this.#fgs.fill(fg);
if (bg != null && !Array.isArray(bg)) this.#bgs.fill(bg);
if (Array.isArray(fg) || Array.isArray(bg)) {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (Array.isArray(fg)) this.#fgs[i] = (fg as IColorLike[][])[y]?.[x] ?? DEFAULT_FG;
if (Array.isArray(bg)) this.#bgs[i] = (bg as IColorLike[][])[y]?.[x] ?? DEFAULT_BG;
}
}
}
} else {
const ichars = chars as IChar[][];
const h = ichars.length;
const w = ichars.reduce((m, r) => Math.max(m, r.length), 0);
this.#chars = ichars.map(row => row.map(ch => ch[0]).join('').padEnd(w, ' '));
this.#fgs = Array(w * h).fill(DEFAULT_FG);
this.#bgs = Array(w * h).fill(DEFAULT_BG);
for (let y = 0; y < h; y++) {
for (let x = 0; x < ichars[y].length; x++) {
const ch = ichars[y][x];
const i = y * w + x;
if (ch[1] != null) this.#fgs[i] = ch[1];
if (ch[2] != null) this.#bgs[i] = ch[2];
}
}
}
}
get [REGION_DATA]() {
return {
chars: this.#chars,
fgs: this.#fgs,
bgs: this.#bgs,
};
}
}

View File

@ -1,6 +1,6 @@
# RPG Engine — Remaining Work # RPG Engine — Remaining Work
## Deferred Combat Features ## Components
### Damage modifiers ### Damage modifiers
Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
@ -8,3 +8,15 @@ the attacker/source that CombatSystem folds into the final damage value.
### Crit / variance ### Crit / variance
RNG layer on top of damage calculation (crit chance, crit multiplier, random range). RNG layer on top of damage calculation (crit chance, crit multiplier, random range).
### Rendering
- Sprite rendering system.
- Generic, use `resourceId` to link image.
- `framesCount`
- `currentFrame`
- `animationSpeed`
- Animation support for sprites.
- Different display systems
- TextDisplay
- BrickDisplay
- Canvas

View File

@ -1,10 +1,12 @@
import type { Class } from "@common/types";
import { Component, type EvalContext } from "../core/world"; import { Component, type EvalContext } from "../core/world";
import { evaluateCondition } from "../utils/conditions"; import { evaluateCondition } from "../utils/conditions";
import { action, component, ComponentTag, tag, variable } from "../utils/decorators"; import { action, component, ComponentTag, getComponentMeta, getComponentName, tag, variable } from "../utils/decorators";
import { Stat } from "./stat"; import { Stat } from "./stat";
abstract class BaseEffect extends Component<{ abstract class BaseEffect extends Component<{
targetStat: string; // component key, e.g. 'health' target: string; // component class ID, e.g. 'Stat'
targetKey?: string; // component key, e.g. 'health'
targetField: 'value' | 'max' | 'min'; targetField: 'value' | 'max' | 'min';
delta: number; delta: number;
duration: number | null; // null = permanent until removed duration: number | null; // null = permanent until removed
@ -14,7 +16,8 @@ abstract class BaseEffect extends Component<{
tag: string | null; // discriminator for stacking; null = no stacking enforcement tag: string | null; // discriminator for stacking; null = no stacking enforcement
}> { }> {
constructor(opts: { constructor(opts: {
targetStat: string; target?: Class<Component<any>>,
targetKey?: string;
delta: number; delta: number;
targetField?: 'value' | 'max' | 'min'; targetField?: 'value' | 'max' | 'min';
duration?: number; duration?: number;
@ -23,7 +26,8 @@ abstract class BaseEffect extends Component<{
tag?: string; tag?: string;
}) { }) {
super({ super({
targetStat: opts.targetStat, target: getComponentName(opts.target ?? Stat)!,
targetKey: opts.targetKey,
targetField: opts.targetField ?? 'value', targetField: opts.targetField ?? 'value',
delta: opts.delta, delta: opts.delta,
duration: opts.duration ?? null, duration: opts.duration ?? null,
@ -49,29 +53,35 @@ export class Effect extends BaseEffect {
if (stacking === 'unique' && siblings.length > 0) { if (stacking === 'unique' && siblings.length > 0) {
// An effect with this tag is already active — discard the incoming one. // An effect with this tag is already active — discard the incoming one.
this.entity.remove(this.key); this.entity.remove(this);
return; return;
} }
if (stacking === 'replace') { if (stacking === 'replace') {
for (const old of siblings) { for (const old of siblings) {
this.entity.remove(old.key); this.entity.remove(old);
} }
} }
} }
const stat = this.entity.get(Stat, this.state.targetStat); const component = getComponentMeta(this.state.target);
if (stat) { if (component) {
stat.applyModifier(this.state.delta, this.state.targetField); const stat = this.entity.get(component.ctor, this.state.targetKey);
this.active = true; if (stat instanceof Stat) {
stat.applyModifier(this.state.delta, this.state.targetField);
this.active = true;
}
} }
} }
override onRemove(): void { override onRemove(): void {
if (!this.active) return; if (!this.active) return;
const stat = this.entity.get(Stat, this.state.targetStat); const component = getComponentMeta(this.state.target);
if (stat) { if (component) {
stat.removeModifier(this.state.delta, this.state.targetField); const stat = this.entity.get(component.ctor, this.state.targetKey);
if (stat instanceof Stat) {
stat.removeModifier(this.state.delta, this.state.targetField);
}
} }
this.active = false; this.active = false;
} }
@ -94,10 +104,12 @@ export class Effect extends BaseEffect {
update(dt: number, ctx: EvalContext): void { update(dt: number, ctx: EvalContext): void {
if (this.state.remaining != null) { if (this.state.remaining != null) {
this.state.remaining -= dt; if (this.state.remaining > 0) {
if (this.state.remaining <= 0) { this.state.remaining -= dt;
this.state.remaining = 0; if (this.state.remaining <= 0) {
this.clear(); this.state.remaining = 0;
this.clear();
}
} }
} else if (this.state.condition != null) { } else if (this.state.condition != null) {
if (!evaluateCondition(this.state.condition, ctx)) { if (!evaluateCondition(this.state.condition, ctx)) {

View File

@ -117,7 +117,7 @@ export class Equipment extends Component<EquipmentState> {
if (!getComponentTags(component).has(ComponentTag.Equippable)) continue; if (!getComponentTags(component).has(ComponentTag.Equippable)) continue;
const effectKey = `__equip_${slotName}_${key}_${id++}`; const effectKey = `__equip_${slotName}_${key}_${id++}`;
this.entity.clone(effectKey, component); this.entity.clone(component, effectKey);
slot.appliedEffectKeys.push(effectKey); slot.appliedEffectKeys.push(effectKey);
} }

View File

@ -1,4 +1,4 @@
import { Component, type EvalContext } from "../core/world"; import { Component, Entity, type EvalContext } from "../core/world";
import type { InventorySlotInput, RPGVariables, SlotId } from "../types"; import type { InventorySlotInput, RPGVariables, SlotId } from "../types";
import { action, component } from "../utils/decorators"; import { action, component } from "../utils/decorators";
import { resolveVariables } from "../utils/variables"; import { resolveVariables } from "../utils/variables";
@ -66,8 +66,9 @@ export class Inventory extends Component<InventoryState> {
} }
@action @action
add({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean { add(arg: { itemId: string; amount: number; slotId?: SlotId } | Entity): boolean {
this.#cachedVars = null; this.#cachedVars = null;
const { itemId, amount = 1, slotId } = (arg instanceof Entity) ? { itemId: arg.id } : arg;
if (amount < 0) return false; if (amount < 0) return false;
if (amount === 0) return true; if (amount === 0) return true;

View File

@ -0,0 +1,28 @@
import { Component } from "../core/world";
import { SpriteSystem } from "../systems/render/sprite";
import { component } from "../utils/decorators";
@component
export class Sprite extends Component<{
frames: string[],
currentFrame: number,
animationDelay: number,
animationCounter: number,
}> {
constructor(frame: string);
constructor(frames: string[], animationDelay?: number);
constructor(frames: string | string[], animationDelay: number = Infinity) {
super({
frames: Array.isArray(frames) ? frames : [frames],
animationDelay,
animationCounter: 0,
currentFrame: 0,
});
}
override onAdd(): void {
if (Number.isFinite(this.state.animationDelay) && !this.world.hasSystem(SpriteSystem)) {
this.world.addSystem(new SpriteSystem());
}
}
}

View File

@ -1,4 +1,4 @@
import { World, Entity, Component, WORLD_ENTITY_COUNTER } from './world'; import { World, Entity, Component, WORLD_ENTITY_COUNTER, COMPONENT_KEY } from './world';
import { getComponentMeta, getComponentName, migrateState } from '../utils/decorators'; import { getComponentMeta, getComponentName, migrateState } from '../utils/decorators';
/** Increment this when the WorldData/EntityData structure itself changes incompatibly. */ /** Increment this when the WorldData/EntityData structure itself changes incompatibly. */
@ -7,7 +7,7 @@ const SCHEMA_VERSION = 1;
interface ComponentData { interface ComponentData {
type: 'component'; type: 'component';
name: string; name: string;
key: string; key: string | null; // null = was symbol-keyed (anonymous)
version: number; version: number;
state: unknown; state: unknown;
} }
@ -37,7 +37,8 @@ function serializeComponent(component: Component<any>): ComponentData {
); );
} }
const meta = getComponentMeta(name)!; const meta = getComponentMeta(name)!;
return { type: 'component', name, key: component.key, version: meta.version, state: component.state }; const key = typeof component[COMPONENT_KEY] === 'symbol' ? null : component.key;
return { type: 'component', name, key, version: meta.version, state: component.state };
} }
function serializeEntity(entity: Entity): EntityData { function serializeEntity(entity: Entity): EntityData {
@ -84,7 +85,12 @@ function deserializeComponent(data: ComponentData): Component<any> {
function deserializeEntity(data: EntityData, world: World): Entity { function deserializeEntity(data: EntityData, world: World): Entity {
const entity = world.createEntity(data.id); const entity = world.createEntity(data.id);
for (const componentData of data.components) { for (const componentData of data.components) {
entity.add(componentData.key, deserializeComponent(componentData)); const component = deserializeComponent(componentData);
if (componentData.key === null) {
entity.add(component);
} else {
entity.add(component, componentData.key);
}
} }
return entity; return entity;
} }

View File

@ -1,3 +1,4 @@
import type { Class } from '@common/types';
import type { RPGActions, RPGVariables } from '../types'; import type { RPGActions, RPGVariables } from '../types';
import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../utils/decorators'; import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../utils/decorators';
@ -10,7 +11,6 @@ interface WorldEvent<T = unknown> {
data?: T; data?: T;
} }
type Class<T> = abstract new (...args: any[]) => T;
type EntityEventHandler = <T>(event: EntityEvent<T>) => void; type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
type WorldEventHandler = <T>(event: WorldEvent<T>) => void; type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
@ -21,6 +21,7 @@ export interface EvalContext {
/** Symbol used by Serialization to access World's entity counter. */ /** Symbol used by Serialization to access World's entity counter. */
export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter'); export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
export const COMPONENT_KEY = Symbol('rpg.component.key');
export abstract class Component<TState = Record<string, unknown>> { export abstract class Component<TState = Record<string, unknown>> {
entity!: Entity; entity!: Entity;
@ -40,8 +41,11 @@ export abstract class Component<TState = Record<string, unknown>> {
? getComponentName(this.constructor) ?? this.constructor.name ? getComponentName(this.constructor) ?? this.constructor.name
: this._key; : this._key;
} }
get [COMPONENT_KEY](): string | symbol { return this._key; }
set key(key: string | symbol) { this._key = key; } set key(key: string | symbol) { this._key = key; }
get world(): World { return this.entity.world; }
protected emit(event: string, data?: unknown): void { protected emit(event: string, data?: unknown): void {
const componentKey = this.key; const componentKey = this.key;
const componentName = getComponentName(this.constructor); const componentName = getComponentName(this.constructor);
@ -118,11 +122,8 @@ export class Entity {
return { self: this, world: this.world }; return { self: this, world: this.world };
} }
add<T extends Component<any>>(component: T): T; add<T extends Component<any>>(component: T, k?: string): T {
add<T extends Component<any>>(key: string, component: T): T; const key = k ?? Symbol();
add<T extends Component<any>>(keyOrComponent: string | T, comp?: T): T {
const key = typeof keyOrComponent === 'string' ? keyOrComponent : Symbol();
const component = (keyOrComponent instanceof Component) ? keyOrComponent : comp;
if (component == null) { if (component == null) {
throw new Error(`Component must be an instance of Component`); throw new Error(`Component must be an instance of Component`);
@ -136,15 +137,14 @@ export class Entity {
return component; return component;
} }
clone<T extends Component<any>>(key: string, component: T): T { clone<T extends Component<any>>(component: T, key: string): T {
const clone = Object.create(component.constructor.prototype) as T; const clone = Object.create(component.constructor.prototype) as T;
(clone as unknown as { state: unknown }).state = structuredClone(component.state); (clone as unknown as { state: unknown }).state = structuredClone(component.state);
return this.add(key, clone); return this.add(clone, key);
} }
get<T extends Component<any>>(key: string): T | undefined; get<T extends Component<any>>(key: string): T | undefined;
get<T extends Component<any>>(ctor: Class<T>): T | undefined; get<T extends Component<any>>(ctor: Class<T>, key?: string): T | undefined;
get<T extends Component<any>>(ctor: Class<T>, key: string): T | undefined;
get<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): T | undefined; get<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): T | undefined;
get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): T | undefined { get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): T | undefined {
if (typeof ctorOrKey === 'string') { if (typeof ctorOrKey === 'string') {
@ -154,6 +154,15 @@ export class Entity {
const c = this.#components.get(key); const c = this.#components.get(key);
return c instanceof ctorOrKey ? c as T : undefined; return c instanceof ctorOrKey ? c as T : undefined;
} }
if (!key) {
for (const [k, c] of this.#components) {
// prefer registered without key
if (typeof k !== 'symbol') continue;
if (c instanceof ctorOrKey) {
return c as T;
}
}
}
for (const c of this.#components.values()) { for (const c of this.#components.values()) {
if (!(c instanceof ctorOrKey)) continue; if (!(c instanceof ctorOrKey)) continue;
if (typeof key === 'function' && !key(c)) continue; if (typeof key === 'function' && !key(c)) continue;
@ -202,7 +211,7 @@ export class Entity {
return; return;
} }
if (ctorOrKey instanceof Component) { if (ctorOrKey instanceof Component) {
this.#removeByKey(ctorOrKey.key); this.#removeByKey(ctorOrKey[COMPONENT_KEY]);
return; return;
} }
if (typeof key === 'string') { if (typeof key === 'string') {
@ -330,7 +339,7 @@ export class World {
for (const [key, component] of source) { for (const [key, component] of source) {
const clone = Object.create(component.constructor.prototype) as Component<any>; const clone = Object.create(component.constructor.prototype) as Component<any>;
(clone as unknown as { state: unknown }).state = structuredClone(component.state); (clone as unknown as { state: unknown }).state = structuredClone(component.state);
target.add(key, clone); target.add(clone, key);
} }
return target; return target;
} }
@ -368,6 +377,10 @@ export class World {
} }
} }
hasSystem(ctor: Class<System>): boolean {
return this.#systems.some(system => system instanceof ctor);
}
update(dt: number) { update(dt: number) {
for (const system of this.#systems) { for (const system of this.#systems) {
system.update(this, dt); system.update(this, dt);

View File

@ -54,9 +54,8 @@ export class CombatSystem extends System {
if (component instanceof EffectTemplate) { if (component instanceof EffectTemplate) {
const s = component.state; const s = component.state;
target.add( target.add(
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
new Effect({ new Effect({
targetStat: s.targetStat, targetKey: s.targetKey,
delta: s.delta, delta: s.delta,
targetField: s.targetField, targetField: s.targetField,
duration: s.duration ?? undefined, duration: s.duration ?? undefined,
@ -64,6 +63,7 @@ export class CombatSystem extends System {
stacking: s.stacking, stacking: s.stacking,
tag: s.tag ?? undefined, tag: s.tag ?? undefined,
}), }),
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
); );
} }
} }

View File

@ -1,14 +1,14 @@
import { Effect } from "../components/effect"; import { Effect } from "../components/effect";
import { System, type Entity, type World } from "../core/world"; import { Component, System, type Entity, type World } from "../core/world";
export class EffectSystem extends System { export class EffectSystem extends System {
override update(world: World, dt: number) { override update(world: World, dt: number) {
const expired: [Entity, string][] = []; const expired: [Entity, Component<any>][] = [];
for (const [entity, key, effect] of world.query(Effect)) { for (const [entity, , effect] of world.query(Effect)) {
effect.update(dt, entity.context); effect.update(dt, entity.context);
if (effect.state.remaining !== null && effect.state.remaining <= 0) { if (effect.state.remaining !== null && effect.state.remaining <= 0) {
expired.push([entity, key]); expired.push([entity, effect]);
} }
} }

View File

@ -0,0 +1,15 @@
import { Sprite } from "@common/rpg/components/sprite";
import { System, World } from "@common/rpg/core/world";
export class SpriteSystem extends System {
override update(world: World, dt: number) {
for (const [, , sprite] of world.query(Sprite)) {
sprite.state.animationCounter += dt;
if (sprite.state.animationCounter >= sprite.state.animationDelay) {
const frameDiff = Math.floor(sprite.state.animationCounter / sprite.state.animationDelay);
sprite.state.animationCounter = sprite.state.animationCounter % sprite.state.animationDelay;
sprite.state.currentFrame = (sprite.state.currentFrame + frameDiff) % sprite.state.frames.length;
}
}
}
}

View File

@ -0,0 +1,24 @@
import { TextRegion, TextDisplay } from "@common/display/text";
import { Position } from "@common/rpg/components/position";
import { Sprite } from "@common/rpg/components/sprite";
import { System, World } from "@common/rpg/core/world";
import { Resources } from "@common/rpg/utils/resources";
export class TextDisplaySystem extends System {
private readonly display: TextDisplay;
constructor(display?: TextDisplay) {
super();
this.display = display ?? new TextDisplay();
}
override update(world: World) {
for (const [, sprite, pos] of world.query(Sprite, Position)) {
const { frames, currentFrame } = sprite.state;
const { x, y } = pos.state;
const data = Resources.get(TextRegion, frames[currentFrame]) ?? new TextRegion(frames[currentFrame]);
this.display.setRegion(x, y, data);
}
}
}

View File

@ -85,7 +85,9 @@ function registerTags<T>(ctor: ComponentConstructor<T>, tags: Iterable<Component
tagsRegistry.set(ctor, set); tagsRegistry.set(ctor, set);
} }
export function getComponentMeta<T>(name: string): ComponentMeta<T> | undefined { export function getComponentMeta<T>(component: string | Function | Component<any>): ComponentMeta<T> | undefined {
const name = typeof component === 'string' ? component : getComponentName(component);
if (!name) return undefined;
return registry.get(name); return registry.get(name);
} }

View File

@ -0,0 +1,37 @@
import type { Class } from "@common/types";
export namespace Resources {
const resources = new Map<Function, Map<string, any>>();
let resourceId = 0;
export function get<T>(id: string): T | undefined;
export function get<T>(ctor: Class<T>, id: string): T | undefined;
export function get<T>(ctorOrId: Class<T> | string, id?: string): T | undefined {
const ctor = typeof ctorOrId === 'string' ? undefined : ctorOrId;
id = typeof ctorOrId === 'string' ? ctorOrId : id;
if (id == null) return undefined;
if (ctor == null) {
for (const ns of resources.values()) {
if (ns.has(id)) {
return ns.get(id) as T;
}
}
return undefined
}
return resources.get(ctor)?.get(id) as T;
}
export function set<T>(id: string, value: NonNullable<T>): void {
const ctor = value.constructor;
const namespace: Map<string, T> = resources.get(ctor) ?? new Map();
namespace.set(id, value);
resources.set(ctor, namespace);
}
export function add<T>(value: NonNullable<T>): string {
const id = `__resource_${resourceId++}`;
Resources.set(id, value);
return id;
}
}

View File

@ -1,2 +1,4 @@
export type Key<T> = keyof T; export type Key<T> = keyof T;
export type Value<T, K extends Key<T> = Key<T>> = T[K]; export type Value<T, K extends Key<T> = Key<T>> = T[K];
export type Class<T> = abstract new (...args: any[]) => T;

View File

@ -17,11 +17,11 @@ describe('CombatSystem — damage', () => {
it('reduces target health by damage value', () => { it('reduces target health by damage value', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' })); sword.add(new Damage({ value: 20, damageType: 'physical' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(80); expect(target.get(Health)!.value).toBe(80);
}); });
@ -29,12 +29,12 @@ describe('CombatSystem — damage', () => {
it('defense on target reduces damage', () => { it('defense on target reduces damage', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' })); sword.add(new Damage({ value: 20, damageType: 'physical' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 8, damageType: 'physical' })); target.add(new Defense({ value: 8, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(88); expect(target.get(Health)!.value).toBe(88);
}); });
@ -42,12 +42,12 @@ describe('CombatSystem — damage', () => {
it('defense only applies to matching damage type', () => { it('defense only applies to matching damage type', () => {
const w = world(); const w = world();
const spell = w.createEntity('spell'); const spell = w.createEntity('spell');
spell.add('dmg', new Damage({ value: 20, damageType: 'fire' })); spell.add(new Damage({ value: 20, damageType: 'fire' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 8, damageType: 'physical' })); target.add(new Defense({ value: 8, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'spell' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'spell' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(80); expect(target.get(Health)!.value).toBe(80);
}); });
@ -55,12 +55,12 @@ describe('CombatSystem — damage', () => {
it('minDamage is enforced when defense exceeds damage', () => { it('minDamage is enforced when defense exceeds damage', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical', minDamage: 3 })); sword.add(new Damage({ value: 5, damageType: 'physical', minDamage: 3 }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 10, damageType: 'physical' })); target.add(new Defense({ value: 10, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(97); expect(target.get(Health)!.value).toBe(97);
}); });
@ -68,10 +68,10 @@ describe('CombatSystem — damage', () => {
it('null sourceId falls back to Damage on attacker', () => { it('null sourceId falls back to Damage on attacker', () => {
const w = world(); const w = world();
const attacker = w.createEntity('attacker'); const attacker = w.createEntity('attacker');
attacker.add('dmg', new Damage({ value: 15, damageType: 'physical' })); attacker.add(new Damage({ value: 15, damageType: 'physical' }));
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: null })); target.add(new Attacked({ attackerId: 'attacker', sourceId: null }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(85); expect(target.get(Health)!.value).toBe(85);
}); });
@ -79,12 +79,12 @@ describe('CombatSystem — damage', () => {
it('multiple attacks in one tick accumulate', () => { it('multiple attacks in one tick accumulate', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add(new Damage({ value: 10, damageType: 'physical' }));
w.createEntity('a'); w.createEntity('a');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(80); expect(target.get(Health)!.value).toBe(80);
}); });
@ -92,11 +92,11 @@ describe('CombatSystem — damage', () => {
it('Attacked components are removed after processing', () => { it('Attacked components are removed after processing', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); sword.add(new Damage({ value: 5, damageType: 'physical' }));
w.createEntity('a'); w.createEntity('a');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.has(Attacked)).toBeFalse(); expect(target.has(Attacked)).toBeFalse();
}); });
@ -104,8 +104,8 @@ describe('CombatSystem — damage', () => {
it('missing attacker entity skips attack gracefully', () => { it('missing attacker entity skips attack gracefully', () => {
const w = world(); const w = world();
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'ghost', sourceId: null })); target.add(new Attacked({ attackerId: 'ghost', sourceId: null }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(100); expect(target.get(Health)!.value).toBe(100);
}); });
@ -114,8 +114,8 @@ describe('CombatSystem — damage', () => {
const w = world(); const w = world();
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'gone' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'gone' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(100); expect(target.get(Health)!.value).toBe(100);
}); });
@ -125,8 +125,8 @@ describe('CombatSystem — damage', () => {
w.createEntity('attacker'); w.createEntity('attacker');
w.createEntity('empty_source'); w.createEntity('empty_source');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(100); expect(target.get(Health)!.value).toBe(100);
}); });
@ -134,10 +134,10 @@ describe('CombatSystem — damage', () => {
it('target with no Health component is skipped gracefully', () => { it('target with no Health component is skipped gracefully', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add(new Damage({ value: 10, damageType: 'physical' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); // should not throw w.update(1); // should not throw
}); });
}); });
@ -146,12 +146,12 @@ describe('CombatSystem — on-hit effects', () => {
it('onHit effect is applied to target on hit', () => { it('onHit effect is applied to target on hit', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add(new Damage({ value: 10, damageType: 'physical' }));
sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10 })); sword.add(new EffectTemplate({ targetKey: 'health', delta: -5, targetField: 'value', duration: 10 }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier
expect(target.getAll(Effect).length).toBe(1); expect(target.getAll(Effect).length).toBe(1);
@ -160,21 +160,21 @@ describe('CombatSystem — on-hit effects', () => {
it('onHit effect on weapon does not affect weapon itself', () => { it('onHit effect on weapon does not affect weapon itself', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add(new Damage({ value: 10, damageType: 'physical' }));
sword.add('str', new Stat({ value: 50 })); sword.add(new Stat({ value: 50 }), 'str');
sword.add('drain', new EffectTemplate({ targetStat: 'str', delta: -99, targetField: 'value' })); sword.add(new EffectTemplate({ targetKey: 'str', delta: -99, targetField: 'value' }));
expect(sword.get(Stat, 'str')!.value).toBe(50); expect(sword.get(Stat, 'str')!.value).toBe(50);
}); });
it('onHit effect expires on target after duration', () => { it('onHit effect expires on target after duration', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); sword.add(new Damage({ value: 5, damageType: 'physical' }));
sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2 })); sword.add(new EffectTemplate({ targetKey: 'health', delta: -10, targetField: 'value', duration: 2 }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
const afterHit = target.get(Health)!.value; // 85 const afterHit = target.get(Health)!.value; // 85
w.update(2); // burn expires w.update(2); // burn expires
@ -187,9 +187,9 @@ describe('Effect stacking', () => {
it('stack: allows multiple effects with the same tag', () => { it('stack: allows multiple effects with the same tag', () => {
const w = world(); const w = world();
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(2); expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(90); expect(target.get(Health)!.value).toBe(90);
}); });
@ -197,9 +197,9 @@ describe('Effect stacking', () => {
it('unique: second effect with same tag is discarded', () => { it('unique: second effect with same tag is discarded', () => {
const w = world(); const w = world();
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(1); expect(target.getAll(Effect).length).toBe(1);
expect(target.get(Health)!.value).toBe(95); expect(target.get(Health)!.value).toBe(95);
}); });
@ -207,9 +207,9 @@ describe('Effect stacking', () => {
it('unique: effects with different tags both apply', () => { it('unique: effects with different tags both apply', () => {
const w = world(); const w = world();
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -3, stacking: 'unique', tag: 'burn' })); target.add(new Effect({ targetKey: 'health', delta: -3, stacking: 'unique', tag: 'burn' }));
expect(target.getAll(Effect).length).toBe(2); expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(92); expect(target.get(Health)!.value).toBe(92);
}); });
@ -217,10 +217,10 @@ describe('Effect stacking', () => {
it('replace: removes existing effect and applies new one', () => { it('replace: removes existing effect and applies new one', () => {
const w = world(); const w = world();
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'replace', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'replace', tag: 'poison' }));
expect(target.get(Health)!.value).toBe(95); expect(target.get(Health)!.value).toBe(95);
target.add('e2', new Effect({ targetStat: 'health', delta: -10, stacking: 'replace', tag: 'poison' })); target.add(new Effect({ targetKey: 'health', delta: -10, stacking: 'replace', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(1); expect(target.getAll(Effect).length).toBe(1);
expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied
}); });
@ -228,9 +228,9 @@ describe('Effect stacking', () => {
it('no tag: unique mode does not enforce limits', () => { it('no tag: unique mode does not enforce limits', () => {
const w = world(); const w = world();
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique' }));
expect(target.getAll(Effect).length).toBe(2); expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(90); expect(target.get(Health)!.value).toBe(90);
}); });
@ -238,13 +238,13 @@ describe('Effect stacking', () => {
it('unique onHit effect is discarded when same tag already on target', () => { it('unique onHit effect is discarded when same tag already on target', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 0, damageType: 'physical' })); sword.add(new Damage({ value: 0, damageType: 'physical' }));
sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, duration: 10, stacking: 'unique', tag: 'burn' })); sword.add(new EffectTemplate({ targetKey: 'health', delta: -5, duration: 10, stacking: 'unique', tag: 'burn' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }), 'health');
target.add('pre', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'burn' })); target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'burn' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded
expect(target.get(Health)!.value).toBe(95); expect(target.get(Health)!.value).toBe(95);
@ -255,13 +255,13 @@ describe('CombatSystem — events', () => {
it("emits 'hit' on target with attack info", () => { it("emits 'hit' on target with attack info", () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'fire' })); sword.add(new Damage({ value: 10, damageType: 'fire' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
const hits: unknown[] = []; const hits: unknown[] = [];
target.on('hit', ({ data }) => hits.push(data)); target.on('hit', ({ data }) => hits.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(hits.length).toBe(1); expect(hits.length).toBe(1);
expect((hits[0] as any).damageType).toBe('fire'); expect((hits[0] as any).damageType).toBe('fire');
@ -273,13 +273,13 @@ describe('CombatSystem — events', () => {
it("emits 'kill' when target health reaches zero", () => { it("emits 'kill' when target health reaches zero", () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 999, damageType: 'physical' })); sword.add(new Damage({ value: 999, damageType: 'physical' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 50, min: 0 })); target.add(new Health({ value: 50, min: 0 }));
const kills: unknown[] = []; const kills: unknown[] = [];
target.on('kill', ({ data }) => kills.push(data)); target.on('kill', ({ data }) => kills.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(kills.length).toBe(1); expect(kills.length).toBe(1);
}); });
@ -287,13 +287,13 @@ describe('CombatSystem — events', () => {
it("does not emit 'kill' when target survives", () => { it("does not emit 'kill' when target survives", () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); sword.add(new Damage({ value: 5, damageType: 'physical' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
const kills: unknown[] = []; const kills: unknown[] = [];
target.on('kill', ({ data }) => kills.push(data)); target.on('kill', ({ data }) => kills.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(kills.length).toBe(0); expect(kills.length).toBe(0);
}); });
@ -301,14 +301,14 @@ describe('CombatSystem — events', () => {
it("emits 'hit' per attack when multiple attacks land", () => { it("emits 'hit' per attack when multiple attacks land", () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); sword.add(new Damage({ value: 5, damageType: 'physical' }));
w.createEntity('a'); w.createEntity('a');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add(new Health({ value: 100, min: 0 }));
const hits: unknown[] = []; const hits: unknown[] = [];
target.on('hit', ({ data }) => hits.push(data)); target.on('hit', ({ data }) => hits.push(data));
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
w.update(1); w.update(1);
expect(hits.length).toBe(2); expect(hits.length).toBe(2);
}); });

View File

@ -1,6 +1,6 @@
import { describe, it, expect } from 'bun:test'; import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world'; import { World } from '@common/rpg/core/world';
import { Stat } from '@common/rpg/components/stat'; import { Health, Stat } from '@common/rpg/components/stat';
import { Effect, EffectTemplate } from '@common/rpg/components/effect'; import { Effect, EffectTemplate } from '@common/rpg/components/effect';
import { EffectSystem } from '@common/rpg/systems/effect'; import { EffectSystem } from '@common/rpg/systems/effect';
@ -13,20 +13,20 @@ function world() {
function withStat(value = 10, min?: number, max?: number) { function withStat(value = 10, min?: number, max?: number) {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('str', new Stat({ value, min, max })); e.add(new Stat({ value, min, max }), 'str');
return { w, e, stat: e.get(Stat, 'str')! }; return { w, e, stat: e.get(Stat, 'str')! };
} }
describe('Effect — onAdd / onRemove', () => { describe('Effect — onAdd / onRemove', () => {
it('applies delta to stat on add', () => { it('applies delta to stat on add', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); e.add(new Effect({ targetKey: 'str', delta: 5 }));
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
}); });
it('reverts delta on remove', () => { it('reverts delta on remove', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); e.add(new Effect({ targetKey: 'str', delta: 5 }), 'fx');
e.remove('fx'); e.remove('fx');
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
@ -34,7 +34,7 @@ describe('Effect — onAdd / onRemove', () => {
it('applies delta to max field', () => { it('applies delta to max field', () => {
const { e } = withStat(10, undefined, 20); const { e } = withStat(10, undefined, 20);
const s = e.get(Stat, 'str')!; const s = e.get(Stat, 'str')!;
e.add('fx', new Effect({ targetStat: 'str', delta: 10, targetField: 'max' })); e.add(new Effect({ targetKey: 'str', delta: 10, targetField: 'max' }), 'fx');
expect(s.max).toBe(30); expect(s.max).toBe(30);
e.remove('fx'); e.remove('fx');
expect(s.max).toBe(20); expect(s.max).toBe(20);
@ -42,21 +42,21 @@ describe('Effect — onAdd / onRemove', () => {
it('active is true after add', () => { it('active is true after add', () => {
const { e } = withStat(10); const { e } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 1 })); e.add(new Effect({ targetKey: 'str', delta: 1 }), 'fx');
expect(e.get(Effect, 'fx')!.active).toBeTrue(); expect(e.get(Effect, 'fx')!.active).toBeTrue();
}); });
it('no-op if target stat is missing', () => { it('no-op if target stat is missing', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
expect(() => e.add('fx', new Effect({ targetStat: 'str', delta: 5 }))).not.toThrow(); expect(() => e.add(new Effect({ targetKey: 'str', delta: 5 }))).not.toThrow();
}); });
}); });
describe('Effect — duration', () => { describe('Effect — duration', () => {
it('expires after duration ticks', () => { it('expires after duration ticks', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 })); e.add(new Effect({ targetKey: 'str', delta: 5, duration: 2 }));
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
w.update(1); w.update(1);
@ -69,16 +69,16 @@ describe('Effect — duration', () => {
it('emits expired before removal', () => { it('emits expired before removal', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add(new Effect({ targetStat: 'str', delta: 1, duration: 1 })); e.add(new Effect({ targetKey: 'str', delta: 1, duration: 1 }), 'fx');
const events: string[] = []; const events: string[] = [];
e.on('Effect.expired', () => events.push('expired')); e.on('Effect(fx).expired', () => events.push('expired'));
w.update(1); w.update(1);
expect(events).toEqual(['expired']); expect(events).toEqual(['expired']);
}); });
it('reset() restarts timer', () => { it('reset() restarts timer', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 })); e.add(new Effect({ targetKey: 'str', delta: 5, duration: 2 }), 'fx');
w.update(1.5); w.update(1.5);
e.get(Effect, 'fx')!.reset(); e.get(Effect, 'fx')!.reset();
w.update(1.5); // would have expired without reset w.update(1.5); // would have expired without reset
@ -88,7 +88,7 @@ describe('Effect — duration', () => {
it('reset(duration) changes duration', () => { it('reset(duration) changes duration', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 })); e.add(new Effect({ targetKey: 'str', delta: 5, duration: 1 }), 'fx');
e.get(Effect, 'fx')!.reset(10); e.get(Effect, 'fx')!.reset(10);
w.update(5); w.update(5);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
@ -96,7 +96,7 @@ describe('Effect — duration', () => {
it('clear() immediately expires effect', () => { it('clear() immediately expires effect', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 })); e.add(new Effect({ targetKey: 'str', delta: 5, duration: 100 }), 'fx');
e.get(Effect, 'fx')!.clear(); e.get(Effect, 'fx')!.clear();
w.update(0.01); w.update(0.01);
expect(e.has(Effect)).toBeFalse(); expect(e.has(Effect)).toBeFalse();
@ -107,7 +107,7 @@ describe('Effect — duration', () => {
describe('Effect — permanent', () => { describe('Effect — permanent', () => {
it('permanent effect is never removed by EffectSystem', () => { it('permanent effect is never removed by EffectSystem', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); e.add(new Effect({ targetKey: 'str', delta: 5 }));
w.update(100); w.update(100);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
@ -117,20 +117,20 @@ describe('Effect — permanent', () => {
describe('Effect — scope: onHit', () => { describe('Effect — scope: onHit', () => {
it('onAdd is a no-op for onHit scope', () => { it('onAdd is a no-op for onHit scope', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 })); e.add(new EffectTemplate({ targetKey: 'str', delta: 99 }));
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('onRemove is a no-op for onHit scope', () => { it('onRemove is a no-op for onHit scope', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 })); e.add(new EffectTemplate({ targetKey: 'str', delta: 99 }));
e.remove('fx'); e.remove('fx');
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('EffectSystem does not tick or remove onHit effects', () => { it('EffectSystem does not tick or remove onHit effects', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 5, duration: 1 })); e.add(new EffectTemplate({ targetKey: 'str', delta: 5, duration: 1 }));
w.update(10); w.update(10);
expect(e.has(EffectTemplate)).toBeTrue(); expect(e.has(EffectTemplate)).toBeTrue();
}); });
@ -139,16 +139,149 @@ describe('Effect — scope: onHit', () => {
describe('Effect — multiple effects on same stat', () => { describe('Effect — multiple effects on same stat', () => {
it('multiple effects stack additively', () => { it('multiple effects stack additively', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('a', new Effect({ targetStat: 'str', delta: 3 })); e.add(new Effect({ targetKey: 'str', delta: 3 }));
e.add('b', new Effect({ targetStat: 'str', delta: 7 })); e.add(new Effect({ targetKey: 'str', delta: 7 }));
expect(stat.value).toBe(20); expect(stat.value).toBe(20);
}); });
it('removing one effect reverts only that delta', () => { it('removing one effect reverts only that delta', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('a', new Effect({ targetStat: 'str', delta: 3 })); e.add(new Effect({ targetKey: 'str', delta: 3 }), 'a');
e.add('b', new Effect({ targetStat: 'str', delta: 7 })); e.add(new Effect({ targetKey: 'str', delta: 7 }), 'b');
e.remove('a'); e.remove('a');
expect(stat.value).toBe(17); expect(stat.value).toBe(17);
}); });
}); });
describe('Effect — targetKey resolution', () => {
it('no targetKey finds a stat added without a key', () => {
const w = world();
const e = w.createEntity();
const stat = e.add(new Stat({ value: 10 })); // Symbol key
e.add(new Effect({ delta: 5 }));
expect(stat.value).toBe(15);
});
it('no targetKey falls back to a string-keyed stat when no anonymous stat exists', () => {
const { e, stat } = withStat(10);
e.add(new Effect({ delta: 5 }));
expect(stat.value).toBe(15);
});
it('no targetKey prefers the anonymous (symbol-keyed) stat over a named one', () => {
const w = world();
const e = w.createEntity();
const named = e.add(new Stat({ value: 10 }), 'str');
const anon = e.add(new Stat({ value: 10 })); // Symbol key — should win
e.add(new Effect({ delta: 5 }));
expect(anon.value).toBe(15);
expect(named.value).toBe(10);
});
it('targetKey selects the correct stat among multiple', () => {
const w = world();
const e = w.createEntity();
const str = e.add(new Stat({ value: 10 }), 'str');
const int = e.add(new Stat({ value: 5 }), 'int');
e.add(new Effect({ targetKey: 'int', delta: 3 }));
expect(int.value).toBe(8);
expect(str.value).toBe(10);
});
it('wrong targetKey is a no-op and active stays false', () => {
const { e, stat } = withStat(10);
e.add(new Effect({ targetKey: 'dex', delta: 5 }), 'fx');
expect(e.get(Effect, 'fx')!.active).toBeFalse();
expect(stat.value).toBe(10);
});
});
describe('Effect — target component class', () => {
it('target: Health targets a Health component by key', () => {
const w = world();
const e = w.createEntity();
const hp = e.add(new Health({ value: 100, min: 0 }), 'hp');
e.add(new Effect({ target: Health, targetKey: 'hp', delta: 20 }));
expect(hp.value).toBe(120);
});
it('removing the effect reverts the delta on the Health component', () => {
const w = world();
const e = w.createEntity();
const hp = e.add(new Health({ value: 100, min: 0 }), 'hp');
e.add(new Effect({ target: Health, targetKey: 'hp', delta: -30 }), 'fx');
expect(hp.value).toBe(70);
e.remove('fx');
expect(hp.value).toBe(100);
});
it('target mismatch (wrong class for given key) is a no-op', () => {
const { e, stat } = withStat(10);
e.add(new Effect({ target: Health, targetKey: 'str', delta: 5 }), 'fx');
expect(e.get(Effect, 'fx')!.active).toBeFalse();
expect(stat.value).toBe(10);
});
it('target: Health without targetKey applies only to Health, not Stat', () => {
const w = world();
const e = w.createEntity();
const str = e.add(new Stat({ value: 10 }), 'str');
const hp = e.add(new Health({ value: 100, min: 0 }), 'hp');
e.add(new Effect({ target: Health, delta: 20 }));
expect(hp.value).toBe(120);
expect(str.value).toBe(10);
});
it('target: Health without targetKey applies to anonymous Health when Stat also present', () => {
const w = world();
const e = w.createEntity();
const str = e.add(new Stat({ value: 10 }), 'str');
const hp = e.add(new Health({ value: 100, min: 0 })); // Symbol key
e.add(new Effect({ target: Health, delta: -10 }));
expect(hp.value).toBe(90);
expect(str.value).toBe(10);
});
});
describe('Effect — condition', () => {
it('condition-based effect persists while condition is true', () => {
const { w, e, stat } = withStat(10);
w.globals.buffed = 1;
e.add(new Effect({ targetKey: 'str', delta: 5, condition: '$.buffed > 0' }));
w.update(10);
expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15);
});
it('condition-based effect is removed when condition becomes false', () => {
const { w, e, stat } = withStat(10);
w.globals.buffed = 1;
e.add(new Effect({ targetKey: 'str', delta: 5, condition: '$.buffed > 0' }));
w.globals.buffed = 0;
w.update(0.01);
expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10);
});
});
describe('Effect — clear() event count', () => {
it('clear() emits expired exactly once even after subsequent update ticks', () => {
const { w, e } = withStat(10);
e.add(new Effect({ targetKey: 'str', delta: 1, duration: 100 }), 'fx');
const count = { n: 0 };
e.on('Effect(fx).expired', () => count.n++);
e.get(Effect, 'fx')!.clear();
w.update(0.01);
w.update(0.01);
expect(count.n).toBe(1);
});
it('natural duration expiry emits expired exactly once', () => {
const { w, e } = withStat(10);
e.add(new Effect({ targetKey: 'str', delta: 1, duration: 1 }), 'fx');
const count = { n: 0 };
e.on('Effect(fx).expired', () => count.n++);
w.update(2);
expect(count.n).toBe(1);
});
});

View File

@ -8,13 +8,13 @@ function world() { return new World(); }
function makeSword(w: World, id = 'sword') { function makeSword(w: World, id = 'sword') {
const sword = w.createEntity(id); const sword = w.createEntity(id);
sword.add('equippable', new Equippable('weapon')); sword.add(new Equippable('weapon'));
return sword; return sword;
} }
function makePlayer(w: World, slots: ConstructorParameters<typeof Equipment>[0] = { slotName: 'weapon', type: 'weapon' }) { function makePlayer(w: World, slots: ConstructorParameters<typeof Equipment>[0] = { slotName: 'weapon', type: 'weapon' }) {
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 })); player.add(new Stat({ value: 10 }), 'str');
player.add(new Equipment(slots)); player.add(new Equipment(slots));
return player; return player;
} }
@ -52,7 +52,7 @@ describe('Equipment — equip', () => {
it('returns false when slot type does not match Equippable.slotType', () => { it('returns false when slot type does not match Equippable.slotType', () => {
const w = world(); const w = world();
const helmet = w.createEntity('helmet'); const helmet = w.createEntity('helmet');
helmet.add('equippable', new Equippable('armor')); helmet.add(new Equippable('armor'));
const player = makePlayer(w); // slot type = 'weapon' const player = makePlayer(w); // slot type = 'weapon'
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'helmet' })).toBeFalse(); expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'helmet' })).toBeFalse();
}); });
@ -61,7 +61,7 @@ describe('Equipment — equip', () => {
const w = world(); const w = world();
makeSword(w); makeSword(w);
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('equipment', new Equipment('slot1')); // generic slot player.add(new Equipment('slot1')); // generic slot
expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue(); expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue();
}); });
@ -91,7 +91,7 @@ describe('Equipment — equip-scope effects', () => {
it('clones equip-scope Effect onto owner on equip', () => { it('clones equip-scope Effect onto owner on equip', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); // scope: equip (default) sword.add(new Effect({ targetKey: 'str', delta: 5 })); // scope: equip (default)
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.get(Stat, 'str')!.value).toBe(15);
@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => {
it('does NOT clone onHit-scope Effect onto owner on equip', () => { it('does NOT clone onHit-scope Effect onto owner on equip', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 })); sword.add(new EffectTemplate({ targetKey: 'str', delta: 99 }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
@ -110,8 +110,8 @@ describe('Equipment — equip-scope effects', () => {
it('clones multiple equip effects', () => { it('clones multiple equip effects', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('a', new Effect({ targetStat: 'str', delta: 3 })); sword.add(new Effect({ targetKey: 'str', delta: 3 }));
sword.add('b', new Effect({ targetStat: 'str', delta: 7 })); sword.add(new Effect({ targetKey: 'str', delta: 7 }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(20); expect(player.get(Stat, 'str')!.value).toBe(20);
@ -120,8 +120,8 @@ describe('Equipment — equip-scope effects', () => {
it('equip + onHit: only equip effects reach owner', () => { it('equip + onHit: only equip effects reach owner', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('passive', new Effect({ targetStat: 'str', delta: 5 })); sword.add(new Effect({ targetKey: 'str', delta: 5 }));
sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 })); sword.add(new EffectTemplate({ targetKey: 'str', delta: 99 }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.get(Stat, 'str')!.value).toBe(15);
@ -133,7 +133,7 @@ describe('Equipment — unequip', () => {
it('unequip reverts cloned effects', () => { it('unequip reverts cloned effects', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); sword.add(new Effect({ targetKey: 'str', delta: 5 }));
const player = makePlayer(w); const player = makePlayer(w);
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' }); eq.equip({ slotName: 'weapon', itemId: 'sword' });
@ -177,7 +177,7 @@ describe('Equipment — queries', () => {
makeSword(w, 'sword1'); makeSword(w, 'sword1');
makeSword(w, 'sword2'); makeSword(w, 'sword2');
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('equipment', new Equipment( player.add(new Equipment(
{ slotName: 'main', type: 'weapon' }, { slotName: 'main', type: 'weapon' },
{ slotName: 'off', type: 'weapon' }, { slotName: 'off', type: 'weapon' },
)); ));
@ -192,7 +192,7 @@ describe('Equipment — queries', () => {
it('findCompatibleSlot prefers typed slot over generic', () => { it('findCompatibleSlot prefers typed slot over generic', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('equipment', new Equipment( player.add(new Equipment(
'generic', 'generic',
{ slotName: 'weapon', type: 'weapon' }, { slotName: 'weapon', type: 'weapon' },
)); ));
@ -203,7 +203,7 @@ describe('Equipment — queries', () => {
it('findCompatibleSlot falls back to generic slot', () => { it('findCompatibleSlot falls back to generic slot', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('equipment', new Equipment('generic')); player.add(new Equipment('generic'));
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBe('generic'); expect(eq.findCompatibleSlot('weapon')).toBe('generic');
}); });
@ -211,7 +211,7 @@ describe('Equipment — queries', () => {
it('findCompatibleSlot returns null when no compatible slot', () => { it('findCompatibleSlot returns null when no compatible slot', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('equipment', new Equipment({ slotName: 'armor', type: 'armor' })); player.add(new Equipment({ slotName: 'armor', type: 'armor' }));
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBeNull(); expect(eq.findCompatibleSlot('weapon')).toBeNull();
}); });

View File

@ -5,7 +5,7 @@ import { Experience } from '@common/rpg/components/experience';
function withXp(spec: ConstructorParameters<typeof Experience>[0]) { function withXp(spec: ConstructorParameters<typeof Experience>[0]) {
const w = new World(); const w = new World();
const e = w.createEntity(); const e = w.createEntity();
e.add('xp', new Experience(spec)); e.add(new Experience(spec), 'xp');
return { e, xp: e.get(Experience, 'xp')! }; return { e, xp: e.get(Experience, 'xp')! };
} }

View File

@ -61,7 +61,7 @@ describe('Inventory — infinite mode', () => {
const w = world(); const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 99 }); Items.register(w, 'coin', 'Coin', { maxStack: 99 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
e.get(Inventory)!.add({ itemId: 'coin', amount: 500 }); e.get(Inventory)!.add({ itemId: 'coin', amount: 500 });
expect(e.get(Inventory)!.getAmount('coin')).toBe(500); expect(e.get(Inventory)!.getAmount('coin')).toBe(500);
}); });
@ -70,7 +70,7 @@ describe('Inventory — infinite mode', () => {
const w = world(); const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 10 }); Items.register(w, 'coin', 'Coin', { maxStack: 10 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
e.get(Inventory)!.add({ itemId: 'coin', amount: 25 }); e.get(Inventory)!.add({ itemId: 'coin', amount: 25 });
// 10 + 10 + 5 = 3 slots // 10 + 10 + 5 = 3 slots
expect(e.get(Inventory)!.getItems().get('coin')).toBe(25); expect(e.get(Inventory)!.getItems().get('coin')).toBe(25);
@ -80,14 +80,14 @@ describe('Inventory — infinite mode', () => {
const w = world(); const w = world();
Items.register(w, 'gem', 'Gem'); Items.register(w, 'gem', 'Gem');
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
expect(e.get(Inventory)!.add({ itemId: 'gem', amount: 1 })).toBeTrue(); expect(e.get(Inventory)!.add({ itemId: 'gem', amount: 1 })).toBeTrue();
}); });
it('warns and returns false for unknown item', () => { it('warns and returns false for unknown item', () => {
const w = world(); const w = world();
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
expect(e.get(Inventory)!.add({ itemId: 'ghost', amount: 1 })).toBeFalse(); expect(e.get(Inventory)!.add({ itemId: 'ghost', amount: 1 })).toBeFalse();
}); });
}); });
@ -97,7 +97,7 @@ describe('Inventory — finite mode (count)', () => {
const w = world(); const w = world();
Items.register(w, 'potion', 'Potion', { maxStack: 5 }); Items.register(w, 'potion', 'Potion', { maxStack: 5 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory(2)); // 2 slots × 5 stack = 10 max e.add(new Inventory(2)); // 2 slots × 5 stack = 10 max
expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 10 })).toBeTrue(); expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 10 })).toBeTrue();
expect(e.get(Inventory)!.getAmount('potion')).toBe(10); expect(e.get(Inventory)!.getAmount('potion')).toBe(10);
}); });
@ -106,7 +106,7 @@ describe('Inventory — finite mode (count)', () => {
const w = world(); const w = world();
Items.register(w, 'potion', 'Potion', { maxStack: 5 }); Items.register(w, 'potion', 'Potion', { maxStack: 5 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory(1)); // 1 slot × 5 = 5 max e.add(new Inventory(1)); // 1 slot × 5 = 5 max
expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 6 })).toBeFalse(); expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 6 })).toBeFalse();
expect(e.get(Inventory)!.getAmount('potion')).toBe(0); expect(e.get(Inventory)!.getAmount('potion')).toBe(0);
}); });
@ -115,7 +115,7 @@ describe('Inventory — finite mode (count)', () => {
const w = world(); const w = world();
Items.register(w, 'stone', 'Stone', { maxStack: 10 }); Items.register(w, 'stone', 'Stone', { maxStack: 10 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory(2)); e.add(new Inventory(2));
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
inv.add({ itemId: 'stone', amount: 8 }); inv.add({ itemId: 'stone', amount: 8 });
inv.add({ itemId: 'stone', amount: 5 }); // fills slot 0 to 10, slot 1 to 3 inv.add({ itemId: 'stone', amount: 5 }); // fills slot 0 to 10, slot 1 to 3
@ -128,7 +128,7 @@ describe('Inventory — named slots', () => {
const w = world(); const w = world();
Items.register(w, 'herb', 'Herb', { maxStack: 10 }); Items.register(w, 'herb', 'Herb', { maxStack: 10 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'pocket', limit: 10 }])); e.add(new Inventory([{ slotId: 'pocket', limit: 10 }]));
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
expect(inv.add({ itemId: 'herb', amount: 3, slotId: 'pocket' })).toBeTrue(); expect(inv.add({ itemId: 'herb', amount: 3, slotId: 'pocket' })).toBeTrue();
expect(inv.getSlotContents('pocket')).toEqual({ itemId: 'herb', amount: 3 }); expect(inv.getSlotContents('pocket')).toEqual({ itemId: 'herb', amount: 3 });
@ -138,7 +138,7 @@ describe('Inventory — named slots', () => {
const w = world(); const w = world();
Items.register(w, 'herb', 'Herb'); Items.register(w, 'herb', 'Herb');
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'pocket', limit: 10 }])); e.add(new Inventory([{ slotId: 'pocket', limit: 10 }]));
expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 1, slotId: 'wallet' })).toBeFalse(); expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 1, slotId: 'wallet' })).toBeFalse();
}); });
@ -146,7 +146,7 @@ describe('Inventory — named slots', () => {
const w = world(); const w = world();
Items.register(w, 'herb', 'Herb', { maxStack: 99 }); Items.register(w, 'herb', 'Herb', { maxStack: 99 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'slot', limit: 5 }])); e.add(new Inventory([{ slotId: 'slot', limit: 5 }]));
expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 6, slotId: 'slot' })).toBeFalse(); expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 6, slotId: 'slot' })).toBeFalse();
}); });
}); });
@ -156,7 +156,7 @@ describe('Inventory — remove', () => {
const w = world(); const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 99 }); Items.register(w, 'coin', 'Coin', { maxStack: 99 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
inv.add({ itemId: 'coin', amount: 50 }); inv.add({ itemId: 'coin', amount: 50 });
inv.remove({ itemId: 'coin', amount: 20 }); inv.remove({ itemId: 'coin', amount: 20 });
@ -167,7 +167,7 @@ describe('Inventory — remove', () => {
const w = world(); const w = world();
Items.register(w, 'coin', 'Coin'); Items.register(w, 'coin', 'Coin');
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
inv.add({ itemId: 'coin', amount: 1 }); inv.add({ itemId: 'coin', amount: 1 });
expect(inv.remove({ itemId: 'coin', amount: 10 })).toBeFalse(); expect(inv.remove({ itemId: 'coin', amount: 10 })).toBeFalse();
@ -178,7 +178,7 @@ describe('Inventory — remove', () => {
const w = world(); const w = world();
Items.register(w, 'gem', 'Gem'); Items.register(w, 'gem', 'Gem');
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'a' }, { slotId: 'b' }])); e.add(new Inventory([{ slotId: 'a' }, { slotId: 'b' }]));
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
inv.add({ itemId: 'gem', amount: 1, slotId: 'a' }); inv.add({ itemId: 'gem', amount: 1, slotId: 'a' });
inv.add({ itemId: 'gem', amount: 1, slotId: 'b' }); inv.add({ itemId: 'gem', amount: 1, slotId: 'b' });
@ -191,7 +191,7 @@ describe('Inventory — remove', () => {
const w = world(); const w = world();
Items.register(w, 'herb', 'Herb'); Items.register(w, 'herb', 'Herb');
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 's' }])); e.add(new Inventory([{ slotId: 's' }]));
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
inv.add({ itemId: 'herb', amount: 1, slotId: 's' }); inv.add({ itemId: 'herb', amount: 1, slotId: 's' });
inv.remove({ itemId: 'herb', amount: 1, slotId: 's' }); inv.remove({ itemId: 'herb', amount: 1, slotId: 's' });
@ -204,7 +204,7 @@ describe('Inventory — getItems / getAmount', () => {
const w = world(); const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 5 }); Items.register(w, 'coin', 'Coin', { maxStack: 5 });
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory(3)); e.add(new Inventory(3));
const inv = e.get(Inventory)!; const inv = e.get(Inventory)!;
inv.add({ itemId: 'coin', amount: 12 }); inv.add({ itemId: 'coin', amount: 12 });
expect(inv.getItems().get('coin')).toBe(12); expect(inv.getItems().get('coin')).toBe(12);
@ -213,7 +213,7 @@ describe('Inventory — getItems / getAmount', () => {
it('getAmount returns 0 for absent item', () => { it('getAmount returns 0 for absent item', () => {
const w = world(); const w = world();
const e = w.createEntity('player'); const e = w.createEntity('player');
e.add('inv', new Inventory()); e.add(new Inventory());
expect(e.get(Inventory)!.getAmount('missing')).toBe(0); expect(e.get(Inventory)!.getAmount('missing')).toBe(0);
}); });
}); });
@ -222,11 +222,11 @@ describe('Inventory — equip', () => {
it('delegates to Equipment.equip', () => { it('delegates to Equipment.equip', () => {
const w = world(); const w = world();
const sword = Items.register(w, 'sword', 'Sword'); const sword = Items.register(w, 'sword', 'Sword');
sword.add('equippable', new Equippable('weapon')); sword.add(new Equippable('weapon'));
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 })); player.add(new Stat({ value: 10 }));
player.add('equipment', new Equipment({ slotName: 'weapon', type: 'weapon' })); player.add(new Equipment({ slotName: 'weapon', type: 'weapon' }));
player.add('inv', new Inventory()); player.add(new Inventory());
const inv = player.get(Inventory)!; const inv = player.get(Inventory)!;
inv.add({ itemId: 'sword', amount: 1 }); inv.add({ itemId: 'sword', amount: 1 });
expect(inv.equip({ itemId: 'sword' })).toBeTrue(); expect(inv.equip({ itemId: 'sword' })).toBeTrue();
@ -238,8 +238,8 @@ describe('Inventory — use', () => {
it('executes Usable actions', () => { it('executes Usable actions', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 })); player.add(new Stat({ value: 10 }), 'str');
player.add('inv', new Inventory()); player.add(new Inventory());
Items.register(w, 'potion', 'Health Potion', { Items.register(w, 'potion', 'Health Potion', {
usable: { actions: [{ type: 'Stat(str).update', arg: 5 }], consumeOnUse: false }, usable: { actions: [{ type: 'Stat(str).update', arg: 5 }], consumeOnUse: false },
}); });
@ -251,7 +251,7 @@ describe('Inventory — use', () => {
it('consumes item when consumeOnUse is true', () => { it('consumes item when consumeOnUse is true', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('inv', new Inventory()); player.add(new Inventory());
Items.register(w, 'herb', 'Herb', { Items.register(w, 'herb', 'Herb', {
maxStack: 99, maxStack: 99,
usable: { actions: [], consumeOnUse: true }, usable: { actions: [], consumeOnUse: true },

View File

@ -22,7 +22,7 @@ function simpleQuest(id = 'q1', actions: Quest['stages'][0]['actions'] = []): Qu
stages: [{ stages: [{
id: 'stage0', id: 'stage0',
description: 'Do the thing', description: 'Do the thing',
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables.done == true' }],
actions, actions,
}], }],
}; };
@ -38,13 +38,13 @@ function twoStageQuest(id = 'q2'): Quest {
{ {
id: 'stage0', id: 'stage0',
description: 'Step 1', description: 'Step 1',
objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'Variables(vars).step >= 1' }], objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'Variables.step >= 1' }],
actions: [], actions: [],
}, },
{ {
id: 'stage1', id: 'stage1',
description: 'Step 2', description: 'Step 2',
objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables(vars).step >= 2' }], objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables.step >= 2' }],
actions: [], actions: [],
}, },
], ],
@ -53,7 +53,7 @@ function twoStageQuest(id = 'q2'): Quest {
function makePlayer(w: World, quests: Quest[] = []) { function makePlayer(w: World, quests: Quest[] = []) {
const player = w.createEntity('player'); const player = w.createEntity('player');
const vars = player.add('vars', new Variables()); const vars = player.add(new Variables());
const questLog = player.add(new QuestLog(quests)); const questLog = player.add(new QuestLog(quests));
return { player, vars, questLog }; return { player, vars, questLog };
} }
@ -254,14 +254,14 @@ describe('QuestLog — availability', () => {
it('quest with unsatisfied condition is not available', () => { it('quest with unsatisfied condition is not available', () => {
const w = world(); const w = world();
const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).unlocked == true'] }; const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] };
const { player, questLog } = makePlayer(w, [quest]); const { player, questLog } = makePlayer(w, [quest]);
expect(questLog.isAvailable('q1', player.context)).toBeFalse(); expect(questLog.isAvailable('q1', player.context)).toBeFalse();
}); });
it('quest with satisfied condition is available', () => { it('quest with satisfied condition is available', () => {
const w = world(); const w = world();
const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).unlocked == true'] }; const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] };
const { player, vars, questLog } = makePlayer(w, [quest]); const { player, vars, questLog } = makePlayer(w, [quest]);
vars.set({ key: 'unlocked', value: true }); vars.set({ key: 'unlocked', value: true });
expect(questLog.isAvailable('q1', player.context)).toBeTrue(); expect(questLog.isAvailable('q1', player.context)).toBeTrue();
@ -357,8 +357,8 @@ describe('Quests.validate', () => {
}); });
it('passes when action type is in the known actions list', () => { it('passes when action type is in the known actions list', () => {
const quest = simpleQuest('q', [{ type: 'Variables(vars).set' }]); const quest = simpleQuest('q', [{ type: 'Variables.set' }]);
const errors = Quests.validate(quest, ['Variables(vars).set']); const errors = Quests.validate(quest, ['Variables.set']);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
@ -405,7 +405,7 @@ describe('QuestSystem — objective completion', () => {
it('runs stage actions before advancing', () => { it('runs stage actions before advancing', () => {
const w = world(); const w = world();
// action sets vars.reward = true on the player entity // action sets vars.reward = true on the player entity
const quest = simpleQuest('q1', [{ type: 'Variables(vars).set', arg: { key: 'reward', value: true } }]); const quest = simpleQuest('q1', [{ type: 'Variables.set', arg: { key: 'reward', value: true } }]);
const { vars, questLog } = makePlayer(w, [quest]); const { vars, questLog } = makePlayer(w, [quest]);
questLog.start('q1'); questLog.start('q1');
@ -462,9 +462,9 @@ describe('QuestSystem — fail conditions', () => {
stages: [{ stages: [{
id: 'stage0', id: 'stage0',
description: 'Do it', description: 'Do it',
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables.done == true' }],
actions: [], actions: [],
failConditions: ['Variables(vars).failed == true'], failConditions: ['Variables.failed == true'],
}], }],
}; };
const { vars, questLog } = makePlayer(w, [quest]); const { vars, questLog } = makePlayer(w, [quest]);
@ -485,9 +485,9 @@ describe('QuestSystem — fail conditions', () => {
stages: [{ stages: [{
id: 'stage0', id: 'stage0',
description: 'Both', description: 'Both',
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables.done == true' }],
actions: [], actions: [],
failConditions: ['Variables(vars).done == true'], // same condition failConditions: ['Variables.done == true'], // same condition
}], }],
}; };
const { vars, questLog } = makePlayer(w, [quest]); const { vars, questLog } = makePlayer(w, [quest]);
@ -504,18 +504,18 @@ describe('QuestSystem — multiple quests', () => {
it('tracks multiple quests independently', () => { it('tracks multiple quests independently', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
const vars = player.add('vars', new Variables()); const vars = player.add(new Variables());
const q1: Quest = { const q1: Quest = {
id: 'q1', title: 'Q1', description: '', id: 'q1', title: 'Q1', description: '',
stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done1 == true' }], actions: [] }], stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables.done1 == true' }], actions: [] }],
}; };
const q2: Quest = { const q2: Quest = {
id: 'q2', title: 'Q2', description: '', id: 'q2', title: 'Q2', description: '',
stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done2 == true' }], actions: [] }], stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables.done2 == true' }], actions: [] }],
}; };
const log = player.add('questLog', new QuestLog([q1, q2])); const log = player.add(new QuestLog([q1, q2]));
log.start('q1'); log.start('q1');
log.start('q2'); log.start('q2');

View File

@ -0,0 +1,124 @@
import { describe, it, expect } from 'bun:test';
import { World, Component, COMPONENT_KEY } from '@common/rpg/core/world';
import { component } from '@common/rpg/utils/decorators';
import { Serialization } from '@common/rpg/core/serialization';
import { Stat } from '@common/rpg/components/stat';
import { Effect } from '@common/rpg/components/effect';
import { EffectSystem } from '@common/rpg/systems/effect';
// ---------- helpers ----------
function roundtrip(world: World): World {
return Serialization.deserialize(Serialization.serialize(world)) as World;
}
function isSymbolKeyed(world: World, entityId: string, ctor: new (...a: any[]) => Component<any>): boolean {
const entity = world.getEntity(entityId)!;
for (const [, component] of entity) {
if (component instanceof ctor) {
return typeof component[COMPONENT_KEY] === 'symbol';
}
}
return false;
}
// ---------- named-key round-trip ----------
describe('Serialization — named key', () => {
it('preserves a string-keyed component', () => {
const w = new World();
const e = w.createEntity('player');
e.add(new Stat({ value: 42 }), 'str');
const w2 = roundtrip(w);
expect(w2.getEntity('player')!.get(Stat, 'str')!.value).toBe(42);
});
it('preserves two components with different named keys', () => {
const w = new World();
const e = w.createEntity('player');
e.add(new Stat({ value: 10 }), 'str');
e.add(new Stat({ value: 5 }), 'int');
const w2 = roundtrip(w);
const e2 = w2.getEntity('player')!;
expect(e2.get(Stat, 'str')!.value).toBe(10);
expect(e2.get(Stat, 'int')!.value).toBe(5);
});
});
// ---------- symbol-key round-trip ----------
describe('Serialization — anonymous (symbol) key', () => {
it('restores a symbol-keyed component as symbol-keyed', () => {
const w = new World();
w.createEntity('e').add(new Stat({ value: 7 })); // no key → Symbol
const w2 = roundtrip(w);
expect(isSymbolKeyed(w2, 'e', Stat)).toBeTrue();
});
it('restored symbol-keyed component is found by type lookup', () => {
const w = new World();
const e = w.createEntity('e');
e.add(new Stat({ value: 7 }));
const w2 = roundtrip(w);
expect(w2.getEntity('e')!.get(Stat)!.value).toBe(7);
});
it('two anonymous components of the same type both survive round-trip', () => {
const w = new World();
const e = w.createEntity('e');
e.add(new Stat({ value: 3 }));
e.add(new Stat({ value: 9 }));
const w2 = roundtrip(w);
const stats = w2.getEntity('e')!.getAll(Stat);
expect(stats.length).toBe(2);
expect(stats.map(s => s.value).sort()).toEqual([3, 9]);
});
it('anonymous and named component of the same type both survive round-trip', () => {
const w = new World();
const e = w.createEntity('e');
e.add(new Stat({ value: 10 }), 'str');
e.add(new Stat({ value: 20 })); // Symbol key
const w2 = roundtrip(w);
const e2 = w2.getEntity('e')!;
expect(e2.get(Stat, 'str')!.value).toBe(10);
// anonymous one still reachable by type
const all = e2.getAll(Stat);
expect(all.length).toBe(2);
});
});
// ---------- effect + stat round-trip ----------
describe('Serialization — Effect round-trip', () => {
it('multiple anonymous Effects both survive round-trip', () => {
const w = new World();
w.addSystem(new EffectSystem());
const e = w.createEntity('target');
e.add(new Stat({ value: 10 }), 'str');
e.add(new Effect({ targetKey: 'str', delta: 3 }));
e.add(new Effect({ targetKey: 'str', delta: 7 }));
const w2 = roundtrip(w);
w2.addSystem(new EffectSystem());
expect(w2.getEntity('target')!.getAll(Effect).length).toBe(2);
});
it('world globals survive round-trip', () => {
const w = new World();
w.globals.score = 99;
w.globals.level = 3;
const w2 = roundtrip(w);
expect(w2.globals.score).toBe(99);
expect(w2.globals.level).toBe(3);
});
it('entity counter continues from saved value', () => {
const w = new World();
w.createEntity();
w.createEntity();
const w2 = roundtrip(w);
const e = w2.createEntity();
expect(e.id).toBe('entity_3');
});
});

View File

@ -8,7 +8,7 @@ describe('Stat — value / base / modifiers', () => {
it('value equals base when no modifiers', () => { it('value equals base when no modifiers', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10 })); e.add(new Stat({ value: 10 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
expect(s.value).toBe(10); expect(s.value).toBe(10);
expect(s.base).toBe(10); expect(s.base).toBe(10);
@ -17,7 +17,7 @@ describe('Stat — value / base / modifiers', () => {
it('set() changes base and value', () => { it('set() changes base and value', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 5 })); e.add(new Stat({ value: 5 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.set(20); s.set(20);
expect(s.base).toBe(20); expect(s.base).toBe(20);
@ -27,7 +27,7 @@ describe('Stat — value / base / modifiers', () => {
it('update() adds to base', () => { it('update() adds to base', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10 })); e.add(new Stat({ value: 10 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.update(5); s.update(5);
expect(s.value).toBe(15); expect(s.value).toBe(15);
@ -36,7 +36,7 @@ describe('Stat — value / base / modifiers', () => {
it('applyModifier shifts value without changing base', () => { it('applyModifier shifts value without changing base', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10 })); e.add(new Stat({ value: 10 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(5); s.applyModifier(5);
expect(s.base).toBe(10); expect(s.base).toBe(10);
@ -46,7 +46,7 @@ describe('Stat — value / base / modifiers', () => {
it('removeModifier reverts applyModifier', () => { it('removeModifier reverts applyModifier', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10 })); e.add(new Stat({ value: 10 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(5); s.applyModifier(5);
s.removeModifier(5); s.removeModifier(5);
@ -56,7 +56,7 @@ describe('Stat — value / base / modifiers', () => {
it('multiple modifiers stack', () => { it('multiple modifiers stack', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10 })); e.add(new Stat({ value: 10 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(3); s.applyModifier(3);
s.applyModifier(7); s.applyModifier(7);
@ -66,7 +66,7 @@ describe('Stat — value / base / modifiers', () => {
it('modifier on max field shifts effective max', () => { it('modifier on max field shifts effective max', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10, max: 20 })); e.add(new Stat({ value: 10, max: 20 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(10, 'max'); s.applyModifier(10, 'max');
expect(s.max).toBe(30); expect(s.max).toBe(30);
@ -75,7 +75,7 @@ describe('Stat — value / base / modifiers', () => {
it('modifier on min field shifts effective min', () => { it('modifier on min field shifts effective min', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0 })); e.add(new Stat({ value: 10, min: 0 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(5, 'min'); s.applyModifier(5, 'min');
expect(s.min).toBe(5); expect(s.min).toBe(5);
@ -84,7 +84,7 @@ describe('Stat — value / base / modifiers', () => {
it('value is clamped to [min, max]', () => { it('value is clamped to [min, max]', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0, max: 20 })); e.add(new Stat({ value: 10, min: 0, max: 20 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.set(100); s.set(100);
expect(s.value).toBe(20); expect(s.value).toBe(20);
@ -95,7 +95,7 @@ describe('Stat — value / base / modifiers', () => {
it('large positibe modifier clamps to max', () => { it('large positibe modifier clamps to max', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10, max: 20 })); e.add(new Stat({ value: 10, max: 20 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(100); s.applyModifier(100);
expect(s.value).toBe(20); expect(s.value).toBe(20);
@ -104,7 +104,7 @@ describe('Stat — value / base / modifiers', () => {
it('large negative modifier clamps to min', () => { it('large negative modifier clamps to min', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0 })); e.add(new Stat({ value: 10, min: 0 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
s.applyModifier(-100); s.applyModifier(-100);
expect(s.value).toBe(0); expect(s.value).toBe(0);
@ -113,7 +113,7 @@ describe('Stat — value / base / modifiers', () => {
it("set() emits 'set' event with prev and value", () => { it("set() emits 'set' event with prev and value", () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10 })); e.add(new Stat({ value: 10 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
const events: unknown[] = []; const events: unknown[] = [];
e.on('Stat(s).set', ({ data }) => events.push(data)); e.on('Stat(s).set', ({ data }) => events.push(data));
@ -124,7 +124,7 @@ describe('Stat — value / base / modifiers', () => {
it("set() does not emit 'set' when value unchanged", () => { it("set() does not emit 'set' when value unchanged", () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0 })); e.add(new Stat({ value: 10, min: 0 }), 's');
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
const events: unknown[] = []; const events: unknown[] = [];
e.on('Stat(s).set', ({ data }) => events.push(data)); e.on('Stat(s).set', ({ data }) => events.push(data));
@ -138,7 +138,7 @@ describe('Health', () => {
it('update reduces health', () => { it('update reduces health', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('health', new Health({ value: 100, min: 0 })); e.add(new Health({ value: 100, min: 0 }));
const h = e.get(Health)!; const h = e.get(Health)!;
h.update(-30); h.update(-30);
expect(h.value).toBe(70); expect(h.value).toBe(70);
@ -147,10 +147,10 @@ describe('Health', () => {
it('update to zero triggers kill()', () => { it('update to zero triggers kill()', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('health', new Health({ value: 10, min: 0 })); e.add(new Health({ value: 10, min: 0 }));
const h = e.get(Health)!; const h = e.get(Health)!;
const killed: unknown[] = []; const killed: unknown[] = [];
e.on('Health(health).killed', () => killed.push(true)); e.on('Health.killed', () => killed.push(true));
h.update(-10); h.update(-10);
expect(killed.length).toBe(1); expect(killed.length).toBe(1);
expect(h.value).toBe(0); expect(h.value).toBe(0);
@ -159,10 +159,10 @@ describe('Health', () => {
it('kill() emits killed and sets value to 0', () => { it('kill() emits killed and sets value to 0', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('health', new Health({ value: 50, min: 0 })); e.add(new Health({ value: 50, min: 0 }));
const h = e.get(Health)!; const h = e.get(Health)!;
const killed: unknown[] = []; const killed: unknown[] = [];
e.on('Health(health).killed', () => killed.push(true)); e.on('Health.killed', () => killed.push(true));
h.kill(); h.kill();
expect(killed.length).toBe(1); expect(killed.length).toBe(1);
expect(h.value).toBe(0); expect(h.value).toBe(0);
@ -171,10 +171,10 @@ describe('Health', () => {
it('kill() does not emit killed twice for overkill', () => { it('kill() does not emit killed twice for overkill', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('health', new Health({ value: 10, min: 0 })); e.add(new Health({ value: 10, min: 0 }));
const h = e.get(Health)!; const h = e.get(Health)!;
const killed: unknown[] = []; const killed: unknown[] = [];
e.on('Health(health).killed', () => killed.push(true)); e.on('Health.killed', () => killed.push(true));
h.update(-999); h.update(-999);
expect(killed.length).toBe(1); expect(killed.length).toBe(1);
}); });
@ -182,10 +182,10 @@ describe('Health', () => {
it('kill() does not emit killed twice for already killed entity', () => { it('kill() does not emit killed twice for already killed entity', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('health', new Health({ value: 10, min: 0 })); e.add(new Health({ value: 10, min: 0 }));
const h = e.get(Health)!; const h = e.get(Health)!;
const killed: unknown[] = []; const killed: unknown[] = [];
e.on('Health(health).killed', () => killed.push(true)); e.on('Health.killed', () => killed.push(true));
h.update(-999); h.update(-999);
h.update(-999); h.update(-999);
expect(killed.length).toBe(1); expect(killed.length).toBe(1);

View File

@ -53,7 +53,7 @@ describe('World — entity management', () => {
constructor() { super({}); } constructor() { super({}); }
override onRemove() { removed.push('ok'); } override onRemove() { removed.push('ok'); }
} }
e.add('t', new Tracked()); e.add(new Tracked());
world.destroyEntity(e); world.destroyEntity(e);
expect(world.getEntity('e')).toBeUndefined(); expect(world.getEntity('e')).toBeUndefined();
expect(removed).toEqual(['ok']); expect(removed).toEqual(['ok']);
@ -73,39 +73,39 @@ describe('Entity — components', () => {
it('add / get by key', () => { it('add / get by key', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
e.add('counter', new Counter(5)); e.add(new Counter(5), 'counter');
expect(e.get<Counter>('counter')?.n).toBe(5); expect(e.get<Counter>('counter')?.n).toBe(5);
}); });
it('get by class', () => { it('get by class', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
e.add('c', new Counter(3)); e.add(new Counter(3));
expect(e.get(Counter)?.n).toBe(3); expect(e.get(Counter)?.n).toBe(3);
}); });
it('get by class and key', () => { it('get by class and key', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
e.add('a', new Counter(1)); e.add(new Counter(1), 'a');
e.add('b', new Counter(2)); e.add(new Counter(2), 'b');
expect(e.get(Counter, 'b')?.n).toBe(2); expect(e.get(Counter, 'b')?.n).toBe(2);
}); });
it('get by class and filter', () => { it('get by class and filter', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
e.add('a', new Counter(10)); e.add(new Counter(10));
e.add('b', new Counter(20)); e.add(new Counter(20));
expect(e.get(Counter, c => c.n === 20)?.n).toBe(20); expect(e.get(Counter, c => c.n === 20)?.n).toBe(20);
}); });
it('getAll returns all matching components', () => { it('getAll returns all matching components', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
e.add('a', new Counter(1)); e.add(new Counter(1));
e.add('b', new Counter(2)); e.add(new Counter(2));
e.add('t', new Tag()); e.add(new Tag());
const all = e.getAll(Counter); const all = e.getAll(Counter);
expect(all.length).toBe(2); expect(all.length).toBe(2);
expect(all.map(c => c.n).sort()).toEqual([1, 2]); expect(all.map(c => c.n).sort()).toEqual([1, 2]);
@ -114,7 +114,7 @@ describe('Entity — components', () => {
it('has by key / by class / by class+key', () => { it('has by key / by class / by class+key', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
e.add('c', new Counter()); e.add(new Counter(), 'c');
expect(e.has('c')).toBeTrue(); expect(e.has('c')).toBeTrue();
expect(e.has('missing')).toBeFalse(); expect(e.has('missing')).toBeFalse();
expect(e.has(Counter)).toBeTrue(); expect(e.has(Counter)).toBeTrue();
@ -131,7 +131,7 @@ describe('Entity — components', () => {
constructor() { super({}); } constructor() { super({}); }
override onRemove() { removed.push(true); } override onRemove() { removed.push(true); }
} }
e.add('r', new R()); e.add(new R(), 'r');
e.remove('r'); e.remove('r');
expect(removed).toEqual([true]); expect(removed).toEqual([true]);
expect(e.has('r')).toBeFalse(); expect(e.has('r')).toBeFalse();
@ -141,7 +141,7 @@ describe('Entity — components', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
const c = new Counter(); const c = new Counter();
e.add('c', c); e.add(c, 'c');
e.remove(c); e.remove(c);
expect(e.has('c')).toBeFalse(); expect(e.has('c')).toBeFalse();
}); });
@ -155,8 +155,8 @@ describe('Entity — components', () => {
override onAdd() { events.push(`add:${this.state.id}`); } override onAdd() { events.push(`add:${this.state.id}`); }
override onRemove() { events.push(`remove:${this.state.id}`); } override onRemove() { events.push(`remove:${this.state.id}`); }
} }
e.add('k', new Ev('a')); e.add(new Ev('a'), 'k');
e.add('k', new Ev('b')); e.add(new Ev('b'), 'k');
expect(events).toEqual(['add:a', 'remove:a', 'add:b']); expect(events).toEqual(['add:a', 'remove:a', 'add:b']);
}); });
@ -164,7 +164,7 @@ describe('Entity — components', () => {
const world = new World(); const world = new World();
const e = world.createEntity('me'); const e = world.createEntity('me');
const c = new Counter(); const c = new Counter();
e.add('mykey', c); e.add(c, 'mykey');
expect(c.entity).toBe(e); expect(c.entity).toBe(e);
expect(c.key).toBe('mykey'); expect(c.key).toBe('mykey');
}); });
@ -175,8 +175,8 @@ describe('Entity — clone', () => {
const world = new World(); const world = new World();
const e = world.createEntity(); const e = world.createEntity();
const orig = new Counter(7); const orig = new Counter(7);
e.add('c', orig); e.add(orig, 'c');
const clone = e.clone('d', orig); const clone = e.clone(orig, 'd');
clone.inc(); clone.inc();
expect(orig.n).toBe(7); expect(orig.n).toBe(7);
expect(clone.n).toBe(8); expect(clone.n).toBe(8);
@ -191,9 +191,9 @@ describe('Entity — clone', () => {
override onAdd() { added.push(true); } override onAdd() { added.push(true); }
} }
const a = new A(); const a = new A();
e.add('a', a); e.add(a, 'a');
added.length = 0; added.length = 0;
e.clone('b', a); e.clone(a, 'b');
expect(added).toEqual([true]); expect(added).toEqual([true]);
}); });
}); });
@ -202,7 +202,7 @@ describe('World — cloneEntity', () => {
it('produces independent deep copy', () => { it('produces independent deep copy', () => {
const world = new World(); const world = new World();
const src = world.createEntity('src'); const src = world.createEntity('src');
src.add('c', new Counter(5)); src.add(new Counter(5));
const copy = world.cloneEntity(src, 'copy'); const copy = world.cloneEntity(src, 'copy');
copy.get(Counter)!.inc(); copy.get(Counter)!.inc();
expect(src.get(Counter)!.n).toBe(5); expect(src.get(Counter)!.n).toBe(5);
@ -216,8 +216,8 @@ describe('World — query', () => {
const a = world.createEntity('a'); const a = world.createEntity('a');
const b = world.createEntity('b'); const b = world.createEntity('b');
world.createEntity('c'); world.createEntity('c');
a.add('c', new Counter()); a.add(new Counter());
b.add('c', new Counter()); b.add(new Counter());
const found = [...world.query(Counter)].map(([e]) => e.id); const found = [...world.query(Counter)].map(([e]) => e.id);
expect(found.sort()).toEqual(['a', 'b']); expect(found.sort()).toEqual(['a', 'b']);
}); });
@ -226,9 +226,9 @@ describe('World — query', () => {
const world = new World(); const world = new World();
const a = world.createEntity('a'); const a = world.createEntity('a');
const b = world.createEntity('b'); const b = world.createEntity('b');
a.add('c', new Counter()); a.add(new Counter());
a.add('t', new Tag()); a.add(new Tag());
b.add('c', new Counter()); b.add(new Counter());
const found = [...world.query(Counter, Tag)].map(([e]) => e.id); const found = [...world.query(Counter, Tag)].map(([e]) => e.id);
expect(found).toEqual(['a']); expect(found).toEqual(['a']);
}); });