Anonymous components
This commit is contained in:
parent
a340c50f57
commit
762feba096
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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++}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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')! };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue