1
0
Fork 0

Namespace variables by component

This commit is contained in:
Pabloader 2026-04-30 13:08:00 +00:00
parent 43da1388e4
commit 6b3c0c77a1
17 changed files with 284 additions and 152 deletions

View File

@ -66,8 +66,8 @@ export class Effect extends Component<{
const stat = this.entity.get(Stat, this.state.targetStat); const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) { if (stat) {
stat.applyModifier(this.state.delta, this.state.targetField); stat.applyModifier(this.state.delta, this.state.targetField);
this.active = true;
} }
this.active = true;
} }
override onRemove(): void { override onRemove(): void {

View File

@ -45,9 +45,9 @@ export type SlotInput =
export class Equipment extends Component<EquipmentState> { export class Equipment extends Component<EquipmentState> {
#cachedVars: RPGVariables | null = null; #cachedVars: RPGVariables | null = null;
constructor(slots: SlotInput[]) { constructor(...slots: SlotInput[]) {
const record: Record<string, SlotRecord> = {}; const record: Record<string, SlotRecord> = {};
for (const s of slots) { for (const s of slots.flat()) {
const slotName = typeof s === 'string' ? s : s.slotName; const slotName = typeof s === 'string' ? s : s.slotName;
const type = typeof s === 'object' && s.type ? s.type : null; const type = typeof s === 'object' && s.type ? s.type : null;
record[slotName] = { slotName, type, itemId: null, appliedEffectKeys: [] }; record[slotName] = { slotName, type, itemId: null, appliedEffectKeys: [] };
@ -155,7 +155,9 @@ export class Equipment extends Component<EquipmentState> {
const result: RPGVariables = {}; const result: RPGVariables = {};
for (const { slotName, itemId } of Object.values(this.state.slots)) { for (const { slotName, itemId } of Object.values(this.state.slots)) {
result[slotName] = itemId ?? ''; if (itemId) {
result[slotName] = itemId;
}
} }
this.#cachedVars = result; this.#cachedVars = result;

View File

@ -1,18 +1,23 @@
import { component } from "../core/registry"; import { component } from "../core/registry";
import { Component, type Entity } from "../core/world"; import { Component, type Entity } from "../core/world";
import { variable } from "../utils/decorators"; import type { RPGVariables } from "../types";
import { action } from "../utils/decorators";
// ── FactionMember ───────────────────────────────────────────────────────────── // ── FactionMember ─────────────────────────────────────────────────────────────
@component @component
export class FactionMember extends Component<{ factionId: string }> { export class FactionMember extends Component<{ factionId: string }> {
@variable('.') readonly member: boolean = true;
constructor(factionId: string) { constructor(factionId: string) {
super({ factionId }); super({ factionId });
} }
get factionId(): string { return this.state.factionId; } get factionId(): string { return this.state.factionId; }
override getVariables(): RPGVariables {
return {
[this.factionId]: true,
}
}
} }
// ── Reputation ──────────────────────────────────────────────────────────────── // ── Reputation ────────────────────────────────────────────────────────────────
@ -23,31 +28,71 @@ export class Reputation extends Component<{ factionId: string; score: number }>
super({ factionId, score }); super({ factionId, score });
} }
@variable('.') get score(): number { return this.state.score; } get score(): number { return this.state.score; }
get factionId(): string { return this.state.factionId; } get factionId(): string { return this.state.factionId; }
adjust(delta: number): void { adjust(delta: number): void {
this.state.score += delta; this.state.score += delta;
} }
override getVariables(): RPGVariables {
return {
[this.factionId]: this.score,
}
}
}
//── FactionManager ────────────────────────────────────────────────────────────
@component
export class FactionManager extends Component<{}> {
constructor() { super({}); }
@action
join(factionId: string): void {
Factions.join(this.entity, factionId);
}
@action
leave(factionId: string): void {
Factions.leave(this.entity, factionId);
}
@action
setReputation({ factionId, value }: { factionId: string, value: number }): void {
Factions.setReputation(this.entity, factionId, value);
}
@action
adjustReputation({ factionId, value }: { factionId: string, value: number }): void {
Factions.adjustReputation(this.entity, factionId, value);
}
} }
// ── Factions namespace ──────────────────────────────────────────────────────── // ── Factions namespace ────────────────────────────────────────────────────────
function memberKey(factionId: string): string { return `faction.${factionId}.member`; }
function repKey(factionId: string): string { return `faction.${factionId}.rep`; }
export namespace Factions { export namespace Factions {
function addFactionManager(entity: Entity) {
if (!entity.has(FactionManager)) {
entity.add(new FactionManager());
}
}
export function join(entity: Entity, factionId: string): void { export function join(entity: Entity, factionId: string): void {
entity.add(memberKey(factionId), new FactionMember(factionId)); addFactionManager(entity);
const existing = entity.get(FactionMember, (c) => c.factionId === factionId);
if (!existing) {
entity.add(new FactionMember(factionId));
}
} }
export function leave(entity: Entity, factionId: string): void { export function leave(entity: Entity, factionId: string): void {
entity.remove(memberKey(factionId)); addFactionManager(entity);
entity.removeAll(FactionMember, (c) => c.factionId === factionId);
} }
export function isMember(entity: Entity, factionId: string): boolean { export function isMember(entity: Entity, factionId: string): boolean {
return entity.has(FactionMember, memberKey(factionId)); return entity.has(FactionMember, (c) => c.factionId === factionId);
} }
export function getFactions(entity: Entity): string[] { export function getFactions(entity: Entity): string[] {
@ -55,26 +100,26 @@ export namespace Factions {
} }
export function getReputation(entity: Entity, factionId: string): number { export function getReputation(entity: Entity, factionId: string): number {
return entity.get(Reputation, repKey(factionId))?.score ?? 0; return entity.get(Reputation, (c) => c.factionId === factionId)?.score ?? 0;
} }
export function setReputation(entity: Entity, factionId: string, value: number): void { export function setReputation(entity: Entity, factionId: string, value: number): void {
const key = repKey(factionId); addFactionManager(entity);
const existing = entity.get(Reputation, key); const existing = entity.get(Reputation, (c) => c.factionId === factionId);
if (existing) { if (existing) {
existing.state.score = value; existing.state.score = value;
} else { } else {
entity.add(key, new Reputation(factionId, value)); entity.add(new Reputation(factionId, value));
} }
} }
export function adjustReputation(entity: Entity, factionId: string, delta: number): void { export function adjustReputation(entity: Entity, factionId: string, delta: number): void {
const key = repKey(factionId); addFactionManager(entity);
const existing = entity.get(Reputation, key); const existing = entity.get(Reputation, (c) => c.factionId === factionId);
if (existing) { if (existing) {
existing.adjust(delta); existing.adjust(delta);
} else { } else {
entity.add(key, new Reputation(factionId, delta)); entity.add(new Reputation(factionId, delta));
} }
} }

View File

@ -3,6 +3,7 @@ import { component } from "../core/registry";
import type { RPGAction } from "../types"; import type { RPGAction } from "../types";
import { action, variable } from "../utils/decorators"; import { action, variable } from "../utils/decorators";
import { executeAction } from "../utils/variables"; import { executeAction } from "../utils/variables";
import { Equippable } from "./equipment";
interface ItemState { interface ItemState {
name: string; name: string;
@ -60,15 +61,21 @@ export namespace Items {
maxStack?: number; maxStack?: number;
description?: string; description?: string;
usable?: { actions: RPGAction[]; consumeOnUse?: boolean }; usable?: { actions: RPGAction[]; consumeOnUse?: boolean };
equippable?: { slotType: string };
} }
export function register(world: World, id: string, name: string, options?: RegisterOptions) { export function register(world: World, id: string, name: string, options?: RegisterOptions) {
const entity = world.createEntity(id); const entity = world.createEntity(id);
entity.add('item', new Item(name, options?.description)); entity.add(new Item(name, options?.description));
if (options?.maxStack !== undefined) if (options?.maxStack !== undefined) {
entity.add('stackable', new Stackable(options.maxStack)); entity.add(new Stackable(options.maxStack));
if (options?.usable) }
entity.add('usable', new Usable(options.usable.actions, options.usable.consumeOnUse)); if (options?.usable) {
entity.add(new Usable(options.usable.actions, options.usable.consumeOnUse));
}
if (options?.equippable) {
entity.add(new Equippable(options.equippable.slotType));
}
return entity; return entity;
} }
} }

View File

@ -75,9 +75,11 @@ export class Health extends Stat {
@action @action
override update(amount: number) { override update(amount: number) {
super.update(amount); if (this.value > 0) {
if (this.value <= 0) { super.update(amount);
this.kill(); if (this.value <= 0) {
this.kill();
}
} }
} }
} }

View File

@ -8,10 +8,6 @@ interface Var {
value: RPGVariables[string]; value: RPGVariables[string];
} }
interface VariablesState {
vars: RPGVariables;
}
/** /**
* Generic runtime key-value store set by dialog actions, quest scripts, and game events * Generic runtime key-value store set by dialog actions, quest scripts, and game events
* values whose keys are only known at runtime or come from data files. * values whose keys are only known at runtime or come from data files.
@ -20,34 +16,32 @@ interface VariablesState {
* (e.g. health, stats, slot definitions). * (e.g. health, stats, slot definitions).
*/ */
@component @component
export class Variables extends Component<VariablesState> { export class Variables extends Component<RPGVariables> {
constructor() { constructor() {
super({ vars: {} }); super({});
} }
override getVariables() { override getVariables() {
return this.state.vars; return this.state;
} }
@action @action
set({ key, value }: Var) { set({ key, value }: Var) {
const prev = this.state.vars[key]; const prev = this.state[key];
this.state.vars[key] = value; this.state[key] = value;
this.emit('set', { key, value, prev }); this.emit('set', { key, value, prev });
return this.state.vars;
} }
@action @action
unset(key: string) { unset(key: string) {
const prev = this.state.vars[key]; const prev = this.state[key];
delete this.state.vars[key]; delete this.state[key];
this.emit('unset', { key, prev }); this.emit('unset', { key, prev });
return this.state.vars;
} }
@action @action
increment({ key, value }: Var) { update({ key, value }: Var) {
const currentValue = this.state.vars[key] ?? 0; const currentValue = this.state[key] ?? 0;
if (typeof currentValue === 'number' && typeof value === 'number') { if (typeof currentValue === 'number' && typeof value === 'number') {
this.set({ key, value: currentValue + value }); this.set({ key, value: currentValue + value });
} else { } else {

View File

@ -1,5 +1,6 @@
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators'; import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
import type { RPGActions, RPGVariables } from '../types'; import type { RPGActions, RPGVariables } from '../types';
import { getComponentName } from './registry';
interface EntityEvent<T = unknown> { interface EntityEvent<T = unknown> {
target: Entity; target: Entity;
@ -24,9 +25,9 @@ export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
export abstract class Component<TState = Record<string, unknown>> { export abstract class Component<TState = Record<string, unknown>> {
entity!: Entity; entity!: Entity;
key!: string;
private _state!: TState; private _state!: TState;
private _key!: string | symbol;
constructor(state: TState) { constructor(state: TState) {
this._state = state; this._state = state;
@ -35,8 +36,21 @@ export abstract class Component<TState = Record<string, unknown>> {
get state(): TState { return this._state; } get state(): TState { return this._state; }
protected set state(state: TState) { this._state = state; } protected set state(state: TState) { this._state = state; }
get key(): string {
return typeof this._key === 'symbol'
? getComponentName(this.constructor) ?? this.constructor.name
: this._key;
}
set key(key: string | symbol) { this._key = key; }
protected emit(event: string, data?: unknown): void { protected emit(event: string, data?: unknown): void {
this.entity.emit(`${this.key}.${event}`, data); const componentKey = this.key;
const componentName = getComponentName(this.constructor);
const key = componentName && componentName !== componentKey
? `${componentName}(${componentKey})`
: componentKey;
this.entity.emit(`${key}.${event}`, data);
} }
onAdd(): void { } onAdd(): void { }
@ -84,7 +98,7 @@ export abstract class System {
type ComponentFilter<T> = (component: T) => boolean; type ComponentFilter<T> = (component: T) => boolean;
export class Entity { export class Entity {
readonly #components = new Map<string, Component>(); readonly #components = new Map<string | symbol, Component>();
constructor( constructor(
readonly id: string, readonly id: string,
@ -95,7 +109,15 @@ export class Entity {
return { self: this, world: this.world }; return { self: this, world: this.world };
} }
add<T extends Component<any>>(key: string, component: T): T { add<T extends Component<any>>(component: T): T;
add<T extends Component<any>>(key: string, component: T): T;
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) {
throw new Error(`Component must be an instance of Component`);
}
const existing = this.#components.get(key); const existing = this.#components.get(key);
if (existing) existing.onRemove(); if (existing) existing.onRemove();
component.entity = this; component.entity = this;
@ -143,20 +165,29 @@ export class Entity {
has(key: string): boolean; has(key: string): boolean;
has<T extends Component<any>>(ctor: Class<T>): boolean; has<T extends Component<any>>(ctor: Class<T>): boolean;
has<T extends Component<any>>(ctor: Class<T>, key: string): boolean; has<T extends Component<any>>(ctor: Class<T>, key: string): boolean;
has<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): boolean { has<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): boolean;
if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey); has<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): boolean {
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey; if (typeof ctorOrKey === 'string') {
return this.#components.has(ctorOrKey);
}
if (typeof key === 'string') {
return this.#components.get(key) instanceof ctorOrKey;
}
for (const c of this.#components.values()) { for (const c of this.#components.values()) {
if (c instanceof ctorOrKey) return true; if (!(c instanceof ctorOrKey)) continue;
if (typeof key === 'function' && !key(c)) continue;
return true;
} }
return false; return false;
} }
remove(key: string): void; remove(key: string): void;
remove<T extends Component<any>>(ctor: Class<T>): void;
remove<T extends Component<any>>(component: T): void; remove<T extends Component<any>>(component: T): void;
remove<T extends Component<any>>(ctor: Class<T>): void;
remove<T extends Component<any>>(ctor: Class<T>, key: string): void; remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string): void { remove<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): void;
remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string | ComponentFilter<T>): void {
if (typeof ctorOrKey === 'string') { if (typeof ctorOrKey === 'string') {
this.#removeByKey(ctorOrKey); this.#removeByKey(ctorOrKey);
return; return;
@ -165,16 +196,28 @@ export class Entity {
this.#removeByKey(ctorOrKey.key); this.#removeByKey(ctorOrKey.key);
return; return;
} }
if (key !== undefined) { if (typeof key === 'string') {
if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key); if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key);
return; return;
} }
for (const [k, c] of this.#components) { for (const [k, c] of this.#components) {
if (c instanceof ctorOrKey) { this.#removeByKey(k); return; } if (!(c instanceof ctorOrKey)) continue;
if (typeof key === 'function' && !key(c)) continue;
this.#removeByKey(k); return;
} }
} }
#removeByKey(key: string): void { removeAll<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): void {
for (const [k, c] of this.#components) {
if (!(c instanceof ctor)) continue;
if (typeof filter === 'function' && !filter(c)) continue;
this.#removeByKey(k); return;
}
}
#removeByKey(key: string | symbol): void {
const c = this.#components.get(key); const c = this.#components.get(key);
if (c) { c.onRemove(); this.#components.delete(key); } if (c) { c.onRemove(); this.#components.delete(key); }
} }
@ -205,7 +248,7 @@ export class Entity {
/** @internal */ /** @internal */
[Symbol.iterator](): IterableIterator<[string, Component]> { [Symbol.iterator](): IterableIterator<[string, Component]> {
return this.#components.entries(); return this.#components.values().map(c => [c.key, c]);
} }
/** @internal */ /** @internal */

View File

@ -1,6 +1,7 @@
import type { RPGAction, RPGActions, RPGVariables } from "../types"; import type { RPGAction, RPGActions, RPGVariables } from "../types";
import { isEvalContext, World } from "../core/world"; import { isEvalContext, World } from "../core/world";
import type { EvalContext, Entity } from "../core/world"; import type { EvalContext, Entity } from "../core/world";
import { getComponentName } from "../core/registry";
export function resolveVariables(target: Entity | World): RPGVariables { export function resolveVariables(target: Entity | World): RPGVariables {
const result: RPGVariables = {}; const result: RPGVariables = {};
@ -11,7 +12,12 @@ export function resolveVariables(target: Entity | World): RPGVariables {
} }
} }
} else { } else {
for (const [key, component] of target) { for (const [componentKey, component] of target) {
const componentName = getComponentName(component.constructor);
const key = componentName && componentName !== componentKey
? `${componentName}(${componentKey})`
: componentKey;
for (const [varKey, value] of Object.entries(component.getVariables())) { for (const [varKey, value] of Object.entries(component.getVariables())) {
if (value != null) { if (value != null) {
if (varKey && varKey !== '.') { if (varKey && varKey !== '.') {
@ -56,7 +62,12 @@ export function resolveActions(target: Entity | World): RPGActions {
} }
} }
} else { } else {
for (const [key, component] of target) { for (const [componentKey, component] of target) {
const componentName = getComponentName(component.constructor);
const key = componentName && componentName !== componentKey
? `${componentName}(${componentKey})`
: componentKey;
for (const [actionKey, fn] of Object.entries(component.getActions())) { for (const [actionKey, fn] of Object.entries(component.getActions())) {
result[`${key}.${actionKey}`] = fn; result[`${key}.${actionKey}`] = fn;
} }

View File

@ -1,41 +1,57 @@
import { World } from "@common/rpg/core/world"; import { World } from "@common/rpg/core/world";
import { Inventory } from "@common/rpg/components/inventory"; import { Inventory } from "@common/rpg/components/inventory";
import { Health } from "@common/rpg/components/stat"; import { Health, Stat } from "@common/rpg/components/stat";
import { Variables } from "@common/rpg/components/variables"; import { Variables } from "@common/rpg/components/variables";
import { QuestLog } from "@common/rpg/components/questLog"; import { QuestLog } from "@common/rpg/components/questLog";
import { QuestSystem } from "@common/rpg/systems/quest"; import { QuestSystem } from "@common/rpg/systems/quest";
import { Items } from "@common/rpg/components/item"; import { Items } from "@common/rpg/components/item";
import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables"; import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables";
import { Serialization } from "@common/rpg/core/serialization"; import { Serialization } from "@common/rpg/core/serialization";
import { Factions } from "@common/rpg/components/faction";
import { Effect } from "@common/rpg/components/effect";
import { Equipment } from "@common/rpg/components/equipment";
export default async function main() { export default async function main() {
const world = new World(); const world = new World();
world.addSystem(new QuestSystem()); world.addSystem(new QuestSystem());
Items.register(world, 'helmet', 'Iron Helmet'); Items.register(world, 'helmet', 'Iron Helmet');
Items.register(world, 'boots', 'Leather Boots', { maxStack: 2 }); const boots = Items.register(world, 'boots', 'Leather Boots', { maxStack: 2, equippable: { slotType: 'feet' } });
boots.add(new Effect({
targetStat: 'agl',
delta: 10,
}));
const player = world.createEntity('player'); const player = world.createEntity('player');
player.add('inventory', new Inventory(['head', 'legs'])); const inventory = player.add(new Inventory());
player.add('health', new Health({value: 100, max: 100})); player.add(new Equipment('head', { slotName: 'feet', type: 'feet' }));
player.add('vars', new Variables()); player.add(new Health({ value: 100, max: 100 }));
player.add('quests', new QuestLog([{ player.add(new Variables());
player.add(new QuestLog([{
id: 'test', id: 'test',
description: 'Test quest', description: 'Test quest',
title: 'Test', title: 'Test',
stages: [], stages: [],
}])); }]));
player.add('str', new Stat({ value: 100 }));
player.add('agl', new Stat({ value: 100 }));
Factions.join(player, 'boobs');
Factions.adjustReputation(player, 'guards', 10);
Factions.adjustReputation(player, 'bandits', -10);
console.log(resolveVariables(world)); console.log(resolveVariables(world));
player.get(Inventory)?.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); inventory.add({ itemId: 'helmet', amount: 1 });
inventory.add({ itemId: 'boots', amount: 1 });
inventory.equip('boots');
const vars = player.get(Variables)!; const vars = player.get(Variables)!;
vars.set({ key: 'test', value: 'test' }); vars.set({ key: 'test', value: 'test' });
await executeAction('player.quests.test.start', world); await executeAction('player.QuestLog.test.start', world);
console.log(resolveActions(world)); console.log(resolveActions(world));
console.log(resolveVariables(world)); console.log(resolveVariables(world));
console.log(Serialization.serialize(world)); console.log(JSON.parse(Serialization.serialize(world)));
} }

View File

@ -13,15 +13,15 @@ describe('Cooldown — initial state', () => {
it('starts not ready', () => { it('starts not ready', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(5)); e.add(new Cooldown(5));
expect(e.get(Cooldown, 'cd')!.ready).toBeFalse(); expect(e.get(Cooldown)!.ready).toBeFalse();
}); });
it('remaining equals duration on creation', () => { it('remaining equals duration on creation', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(3)); e.add(new Cooldown(3));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
expect(cd.state.remaining).toBe(3); expect(cd.state.remaining).toBe(3);
expect(cd.state.duration).toBe(3); expect(cd.state.duration).toBe(3);
}); });
@ -31,8 +31,8 @@ describe('Cooldown — clear()', () => {
it('marks as ready immediately', () => { it('marks as ready immediately', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(10)); e.add(new Cooldown(10));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.clear(); cd.clear();
expect(cd.ready).toBeTrue(); expect(cd.ready).toBeTrue();
}); });
@ -40,21 +40,21 @@ describe('Cooldown — clear()', () => {
it("emits 'ready' event", () => { it("emits 'ready' event", () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(10)); e.add(new Cooldown(10));
const events: unknown[] = []; const events: unknown[] = [];
e.on('cd.ready', () => events.push(true)); e.on('Cooldown.ready', () => events.push(true));
e.get(Cooldown, 'cd')!.clear(); e.get(Cooldown)!.clear();
expect(events.length).toBe(1); expect(events.length).toBe(1);
}); });
it('is no-op when already ready', () => { it('is no-op when already ready', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(1)); e.add(new Cooldown(1));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.clear(); cd.clear();
const events: unknown[] = []; const events: unknown[] = [];
e.on('cd.ready', () => events.push(true)); e.on('Cooldown.ready', () => events.push(true));
cd.clear(); cd.clear();
expect(events.length).toBe(0); expect(events.length).toBe(0);
}); });
@ -64,8 +64,8 @@ describe('Cooldown — reset()', () => {
it('resets remaining to duration', () => { it('resets remaining to duration', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(5)); e.add(new Cooldown(5));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.clear(); cd.clear();
cd.reset(); cd.reset();
expect(cd.ready).toBeFalse(); expect(cd.ready).toBeFalse();
@ -75,8 +75,8 @@ describe('Cooldown — reset()', () => {
it('reset(duration) changes duration and remaining', () => { it('reset(duration) changes duration and remaining', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(5)); e.add(new Cooldown(5));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.reset(10); cd.reset(10);
expect(cd.state.duration).toBe(10); expect(cd.state.duration).toBe(10);
expect(cd.state.remaining).toBe(10); expect(cd.state.remaining).toBe(10);
@ -87,8 +87,8 @@ describe('Cooldown — update(dt)', () => {
it('counts down remaining', () => { it('counts down remaining', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(5)); e.add(new Cooldown(5));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.update(2); cd.update(2);
expect(cd.state.remaining).toBe(3); expect(cd.state.remaining).toBe(3);
expect(cd.ready).toBeFalse(); expect(cd.ready).toBeFalse();
@ -97,8 +97,8 @@ describe('Cooldown — update(dt)', () => {
it('becomes ready when remaining reaches zero', () => { it('becomes ready when remaining reaches zero', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(3)); e.add(new Cooldown(3));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.update(3); cd.update(3);
expect(cd.ready).toBeTrue(); expect(cd.ready).toBeTrue();
}); });
@ -106,18 +106,18 @@ describe('Cooldown — update(dt)', () => {
it("emits 'ready' when countdown completes", () => { it("emits 'ready' when countdown completes", () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(2)); e.add(new Cooldown(2));
const events: unknown[] = []; const events: unknown[] = [];
e.on('cd.ready', () => events.push(true)); e.on('Cooldown.ready', () => events.push(true));
e.get(Cooldown, 'cd')!.update(2); e.get(Cooldown)!.update(2);
expect(events.length).toBe(1); expect(events.length).toBe(1);
}); });
it('does not go below zero', () => { it('does not go below zero', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(1)); e.add(new Cooldown(1));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.update(100); cd.update(100);
expect(cd.state.remaining).toBe(0); expect(cd.state.remaining).toBe(0);
}); });
@ -125,11 +125,11 @@ describe('Cooldown — update(dt)', () => {
it('is no-op when already ready', () => { it('is no-op when already ready', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(1)); e.add(new Cooldown(1));
const cd = e.get(Cooldown, 'cd')!; const cd = e.get(Cooldown)!;
cd.clear(); cd.clear();
const events: unknown[] = []; const events: unknown[] = [];
e.on('cd.ready', () => events.push(true)); e.on('Cooldown.ready', () => events.push(true));
cd.update(1); cd.update(1);
expect(events.length).toBe(0); expect(events.length).toBe(0);
}); });
@ -139,31 +139,31 @@ describe('CooldownSystem', () => {
it('drives all cooldowns each tick', () => { it('drives all cooldowns each tick', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(2)); e.add(new Cooldown(2));
w.update(1); w.update(1);
expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1); expect(e.get(Cooldown)!.state.remaining).toBe(1);
}); });
it('marks cooldown ready after enough ticks', () => { it('marks cooldown ready after enough ticks', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(2)); e.add(new Cooldown(2));
const events: unknown[] = []; const events: unknown[] = [];
e.on('cd.ready', () => events.push(true)); e.on('Cooldown.ready', () => events.push(true));
w.update(1); w.update(1);
w.update(1); w.update(1);
expect(events.length).toBe(1); expect(events.length).toBe(1);
expect(e.get(Cooldown, 'cd')!.ready).toBeTrue(); expect(e.get(Cooldown)!.ready).toBeTrue();
}); });
it('handles multiple cooldowns on different entities', () => { it('handles multiple cooldowns on different entities', () => {
const w = world(); const w = world();
const a = w.createEntity('a'); const a = w.createEntity('a');
const b = w.createEntity('b'); const b = w.createEntity('b');
a.add('cd', new Cooldown(1)); a.add(new Cooldown(1));
b.add('cd', new Cooldown(3)); b.add(new Cooldown(3));
w.update(2); w.update(2);
expect(a.get(Cooldown, 'cd')!.ready).toBeTrue(); expect(a.get(Cooldown)!.ready).toBeTrue();
expect(b.get(Cooldown, 'cd')!.ready).toBeFalse(); expect(b.get(Cooldown)!.ready).toBeFalse();
}); });
}); });

View File

@ -69,9 +69,9 @@ 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('fx', new Effect({ targetStat: 'str', delta: 1, duration: 1 })); e.add(new Effect({ targetStat: 'str', delta: 1, duration: 1 }));
const events: string[] = []; const events: string[] = [];
e.on('fx.expired', () => events.push('expired')); e.on('Effect.expired', () => events.push('expired'));
w.update(1); w.update(1);
expect(events).toEqual(['expired']); expect(events).toEqual(['expired']);
}); });

View File

@ -12,10 +12,10 @@ function makeSword(w: World, id = 'sword') {
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('str', new Stat({ value: 10 }));
player.add('equipment', new Equipment(slots)); player.add(new Equipment(slots));
return player; return player;
} }
@ -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('equipment', 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();
}); });
@ -81,7 +81,7 @@ describe('Equipment — equip', () => {
makeSword(w); makeSword(w);
const player = makePlayer(w); const player = makePlayer(w);
const events: unknown[] = []; const events: unknown[] = [];
player.on('equipment.equip', ({ data }) => events.push(data)); player.on('Equipment.equip', ({ data }) => events.push(data));
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]); expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
}); });
@ -165,7 +165,7 @@ describe('Equipment — unequip', () => {
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' }); eq.equip({ slotName: 'weapon', itemId: 'sword' });
const events: unknown[] = []; const events: unknown[] = [];
player.on('equipment.unequip', ({ data }) => events.push(data)); player.on('Equipment.unequip', ({ data }) => events.push(data));
eq.unequip('weapon'); eq.unequip('weapon');
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]); expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
}); });
@ -177,10 +177,10 @@ 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('equipment', new Equipment(
{ slotName: 'main', type: 'weapon' }, { slotName: 'main', type: 'weapon' },
{ slotName: 'off', type: 'weapon' }, { slotName: 'off', type: 'weapon' },
])); ));
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
eq.equip({ slotName: 'main', itemId: 'sword1' }); eq.equip({ slotName: 'main', itemId: 'sword1' });
eq.equip({ slotName: 'off', itemId: 'sword2' }); eq.equip({ slotName: 'off', itemId: 'sword2' });
@ -192,10 +192,10 @@ 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('equipment', new Equipment(
'generic', 'generic',
{ slotName: 'weapon', type: 'weapon' }, { slotName: 'weapon', type: 'weapon' },
])); ));
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBe('weapon'); expect(eq.findCompatibleSlot('weapon')).toBe('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('equipment', 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('equipment', new Equipment({ slotName: 'armor', type: 'armor' }));
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBeNull(); expect(eq.findCompatibleSlot('weapon')).toBeNull();
}); });

View File

@ -47,7 +47,7 @@ describe('Experience — award XP (array spec)', () => {
it("emits 'levelup' with prev and new level", () => { it("emits 'levelup' with prev and new level", () => {
const { e, xp } = withXp([100]); const { e, xp } = withXp([100]);
const events: unknown[] = []; const events: unknown[] = [];
e.on('xp.levelup', ({ data }) => events.push(data)); e.on('Experience(xp).levelup', ({ data }) => events.push(data));
xp.award(100); xp.award(100);
expect(events).toEqual([{ level: 2, prev: 1 }]); expect(events).toEqual([{ level: 2, prev: 1 }]);
}); });
@ -62,7 +62,7 @@ describe('Experience — award XP (array spec)', () => {
it("emits 'levelup' for each level gained", () => { it("emits 'levelup' for each level gained", () => {
const { e, xp } = withXp([100, 200]); const { e, xp } = withXp([100, 200]);
const events: unknown[] = []; const events: unknown[] = [];
e.on('xp.levelup', ({ data }) => events.push(data)); e.on('Experience(xp).levelup', ({ data }) => events.push(data));
xp.award(300); // gains 2 levels xp.award(300); // gains 2 levels
expect(events.length).toBe(2); expect(events.length).toBe(2);
expect((events[0] as any).level).toBe(2); expect((events[0] as any).level).toBe(2);

View File

@ -39,7 +39,7 @@ describe('FactionMember', () => {
const w = world(); const w = world();
const e = w.createEntity('player'); const e = w.createEntity('player');
Factions.join(e, 'guards'); Factions.join(e, 'guards');
expect(resolveVariables(w)['player.faction.guards.member']).toBeTrue(); expect(resolveVariables(w)['player.FactionMember.guards']).toBeTrue();
}); });
}); });
@ -85,7 +85,7 @@ describe('Reputation', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.setReputation(e, 'guards', 10); Factions.setReputation(e, 'guards', 10);
const comp = e.get(Reputation, 'faction.guards.rep')!; const comp = e.get(Reputation, (c) => c.factionId == 'guards')!;
comp.adjust(30); comp.adjust(30);
expect(comp.score).toBe(40); expect(comp.score).toBe(40);
}); });
@ -94,7 +94,7 @@ describe('Reputation', () => {
const w = world(); const w = world();
const e = w.createEntity('player'); const e = w.createEntity('player');
Factions.setReputation(e, 'guards', 50); Factions.setReputation(e, 'guards', 50);
expect(resolveVariables(w)['player.faction.guards.rep']).toBe(50); expect(resolveVariables(w)['player.Reputation.guards']).toBe(50);
}); });
}); });

View File

@ -241,7 +241,7 @@ describe('Inventory — use', () => {
player.add('str', new Stat({ value: 10 })); player.add('str', new Stat({ value: 10 }));
player.add('inv', new Inventory()); player.add('inv', new Inventory());
Items.register(w, 'potion', 'Health Potion', { Items.register(w, 'potion', 'Health Potion', {
usable: { actions: [{ type: 'str.update', arg: 5 }], consumeOnUse: false }, usable: { actions: [{ type: 'Stat(str).update', arg: 5 }], consumeOnUse: false },
}); });
player.get(Inventory)!.add({ itemId: 'potion', amount: 1 }); player.get(Inventory)!.add({ itemId: 'potion', amount: 1 });
player.get(Inventory)!.use({ itemId: 'potion' }); player.get(Inventory)!.use({ itemId: 'potion' });

View File

@ -22,7 +22,7 @@ function simpleQuest(id = 'q1', actions: Quest['stages'][0]['actions'] = []): Qu
stages: [{ stages: [{
id: 'stage0', id: 'stage0',
description: 'Do the thing', description: 'Do the thing',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }], objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).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: 'vars.step >= 1' }], objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'Variables(vars).step >= 1' }],
actions: [], actions: [],
}, },
{ {
id: 'stage1', id: 'stage1',
description: 'Step 2', description: 'Step 2',
objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'vars.step >= 2' }], objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables(vars).step >= 2' }],
actions: [], actions: [],
}, },
], ],
@ -54,7 +54,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('vars', new Variables());
const questLog = player.add('questLog', new QuestLog(quests)); const questLog = player.add(new QuestLog(quests));
return { player, vars, questLog }; return { player, vars, questLog };
} }
@ -170,7 +170,7 @@ describe('QuestLog — events', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = []; const events: unknown[] = [];
player.on('questLog.started', ({ data }) => events.push(data)); player.on('QuestLog.started', ({ data }) => events.push(data));
questLog.start('q1'); questLog.start('q1');
expect(events).toEqual([{ questId: 'q1' }]); expect(events).toEqual([{ questId: 'q1' }]);
}); });
@ -179,7 +179,7 @@ describe('QuestLog — events', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = []; const events: unknown[] = [];
player.on('questLog.completed', ({ data }) => events.push(data)); player.on('QuestLog.completed', ({ data }) => events.push(data));
questLog.start('q1'); questLog.start('q1');
questLog.complete('q1'); questLog.complete('q1');
expect(events).toEqual([{ questId: 'q1' }]); expect(events).toEqual([{ questId: 'q1' }]);
@ -189,7 +189,7 @@ describe('QuestLog — events', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = []; const events: unknown[] = [];
player.on('questLog.failed', ({ data }) => events.push(data)); player.on('QuestLog.failed', ({ data }) => events.push(data));
questLog.start('q1'); questLog.start('q1');
questLog.fail('q1'); questLog.fail('q1');
expect(events).toEqual([{ questId: 'q1' }]); expect(events).toEqual([{ questId: 'q1' }]);
@ -199,7 +199,7 @@ describe('QuestLog — events', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = []; const events: unknown[] = [];
player.on('questLog.abandoned', ({ data }) => events.push(data)); player.on('QuestLog.abandoned', ({ data }) => events.push(data));
questLog.start('q1'); questLog.start('q1');
questLog.abandon('q1'); questLog.abandon('q1');
expect(events).toEqual([{ questId: 'q1' }]); expect(events).toEqual([{ questId: 'q1' }]);
@ -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: ['vars.unlocked == true'] }; const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).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: ['vars.unlocked == true'] }; const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).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();
@ -315,7 +315,7 @@ describe('QuestLog — _advance', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [twoStageQuest()]); const { player, questLog } = makePlayer(w, [twoStageQuest()]);
const events: unknown[] = []; const events: unknown[] = [];
player.on('questLog.stage', ({ data }) => events.push(data)); player.on('QuestLog.stage', ({ data }) => events.push(data));
questLog.start('q2'); questLog.start('q2');
questLog._advance('q2'); questLog._advance('q2');
expect((events[0] as any).questId).toBe('q2'); expect((events[0] as any).questId).toBe('q2');
@ -334,7 +334,7 @@ describe('QuestLog — _advance', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = []; const events: unknown[] = [];
player.on('questLog.completed', ({ data }) => events.push(data)); player.on('QuestLog.completed', ({ data }) => events.push(data));
questLog.start('q1'); questLog.start('q1');
questLog._advance('q1'); questLog._advance('q1');
expect(events).toEqual([{ questId: 'q1' }]); expect(events).toEqual([{ questId: 'q1' }]);
@ -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: 'vars.set' }]); const quest = simpleQuest('q', [{ type: 'Variables(vars).set' }]);
const errors = Quests.validate(quest, ['vars.set']); const errors = Quests.validate(quest, ['Variables(vars).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: 'vars.set', arg: { key: 'reward', value: true } }]); const quest = simpleQuest('q1', [{ type: 'Variables(vars).set', arg: { key: 'reward', value: true } }]);
const { vars, questLog } = makePlayer(w, [quest]); const { vars, questLog } = makePlayer(w, [quest]);
questLog.start('q1'); questLog.start('q1');
@ -413,7 +413,7 @@ describe('QuestSystem — objective completion', () => {
w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed'); expect(questLog.getState('q1')?.status).toBe('completed');
expect(vars.state.vars['reward']).toBe(true); expect(vars.state.reward).toBe(true);
}); });
}); });
@ -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: 'vars.done == true' }], objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
actions: [], actions: [],
failConditions: ['vars.failed == true'], failConditions: ['Variables(vars).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: 'vars.done == true' }], objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
actions: [], actions: [],
failConditions: ['vars.done == true'], // same condition failConditions: ['Variables(vars).done == true'], // same condition
}], }],
}; };
const { vars, questLog } = makePlayer(w, [quest]); const { vars, questLog } = makePlayer(w, [quest]);
@ -508,11 +508,11 @@ describe('QuestSystem — multiple quests', () => {
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: 'vars.done1 == true' }], actions: [] }], stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).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: 'vars.done2 == true' }], actions: [] }], stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done2 == true' }], actions: [] }],
}; };
const log = player.add('questLog', new QuestLog([q1, q2])); const log = player.add('questLog', new QuestLog([q1, q2]));

View File

@ -116,7 +116,7 @@ describe('Stat — value / base / modifiers', () => {
e.add('s', new Stat({ value: 10 })); e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
const events: unknown[] = []; const events: unknown[] = [];
e.on('s.set', ({ data }) => events.push(data)); e.on('Stat(s).set', ({ data }) => events.push(data));
s.set(20); s.set(20);
expect(events).toEqual([{ prev: 10, value: 20 }]); expect(events).toEqual([{ prev: 10, value: 20 }]);
}); });
@ -127,7 +127,7 @@ describe('Stat — value / base / modifiers', () => {
e.add('s', new Stat({ value: 10, min: 0 })); e.add('s', new Stat({ value: 10, min: 0 }));
const s = e.get(Stat, 's')!; const s = e.get(Stat, 's')!;
const events: unknown[] = []; const events: unknown[] = [];
e.on('s.set', ({ data }) => events.push(data)); e.on('Stat(s).set', ({ data }) => events.push(data));
s.set(-100); // clamped to 0, still changes s.set(-100); // clamped to 0, still changes
s.set(-200); // still 0, no change s.set(-200); // still 0, no change
expect(events.length).toBe(1); expect(events.length).toBe(1);
@ -150,7 +150,7 @@ describe('Health', () => {
e.add('health', new Health({ value: 10, min: 0 })); e.add('health', 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.killed', () => killed.push(true)); e.on('Health(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);
@ -162,7 +162,7 @@ describe('Health', () => {
e.add('health', new Health({ value: 50, min: 0 })); e.add('health', 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.killed', () => killed.push(true)); e.on('Health(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);
@ -174,7 +174,19 @@ describe('Health', () => {
e.add('health', new Health({ value: 10, min: 0 })); e.add('health', 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.killed', () => killed.push(true)); e.on('Health(health).killed', () => killed.push(true));
h.update(-999);
expect(killed.length).toBe(1);
});
it('kill() does not emit killed twice for already killed entity', () => {
const w = world();
const e = w.createEntity();
e.add('health', new Health({ value: 10, min: 0 }));
const h = e.get(Health)!;
const killed: unknown[] = [];
e.on('Health(health).killed', () => killed.push(true));
h.update(-999);
h.update(-999); h.update(-999);
expect(killed.length).toBe(1); expect(killed.length).toBe(1);
}); });