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,9 +66,9 @@ export class Effect extends Component<{
const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) {
stat.applyModifier(this.state.delta, this.state.targetField);
}
this.active = true;
}
}
override onRemove(): void {
if (!this.active) return;

View File

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

View File

@ -1,18 +1,23 @@
import { component } from "../core/registry";
import { Component, type Entity } from "../core/world";
import { variable } from "../utils/decorators";
import type { RPGVariables } from "../types";
import { action } from "../utils/decorators";
// ── FactionMember ─────────────────────────────────────────────────────────────
@component
export class FactionMember extends Component<{ factionId: string }> {
@variable('.') readonly member: boolean = true;
constructor(factionId: string) {
super({ factionId });
}
get factionId(): string { return this.state.factionId; }
override getVariables(): RPGVariables {
return {
[this.factionId]: true,
}
}
}
// ── Reputation ────────────────────────────────────────────────────────────────
@ -23,31 +28,71 @@ export class Reputation extends Component<{ factionId: string; score: number }>
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; }
adjust(delta: number): void {
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 ────────────────────────────────────────────────────────
function memberKey(factionId: string): string { return `faction.${factionId}.member`; }
function repKey(factionId: string): string { return `faction.${factionId}.rep`; }
export namespace Factions {
function addFactionManager(entity: Entity) {
if (!entity.has(FactionManager)) {
entity.add(new FactionManager());
}
}
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 {
entity.remove(memberKey(factionId));
addFactionManager(entity);
entity.removeAll(FactionMember, (c) => c.factionId === factionId);
}
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[] {
@ -55,26 +100,26 @@ export namespace Factions {
}
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 {
const key = repKey(factionId);
const existing = entity.get(Reputation, key);
addFactionManager(entity);
const existing = entity.get(Reputation, (c) => c.factionId === factionId);
if (existing) {
existing.state.score = value;
} else {
entity.add(key, new Reputation(factionId, value));
entity.add(new Reputation(factionId, value));
}
}
export function adjustReputation(entity: Entity, factionId: string, delta: number): void {
const key = repKey(factionId);
const existing = entity.get(Reputation, key);
addFactionManager(entity);
const existing = entity.get(Reputation, (c) => c.factionId === factionId);
if (existing) {
existing.adjust(delta);
} 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 { action, variable } from "../utils/decorators";
import { executeAction } from "../utils/variables";
import { Equippable } from "./equipment";
interface ItemState {
name: string;
@ -60,15 +61,21 @@ export namespace Items {
maxStack?: number;
description?: string;
usable?: { actions: RPGAction[]; consumeOnUse?: boolean };
equippable?: { slotType: string };
}
export function register(world: World, id: string, name: string, options?: RegisterOptions) {
const entity = world.createEntity(id);
entity.add('item', new Item(name, options?.description));
if (options?.maxStack !== undefined)
entity.add('stackable', new Stackable(options.maxStack));
if (options?.usable)
entity.add('usable', new Usable(options.usable.actions, options.usable.consumeOnUse));
entity.add(new Item(name, options?.description));
if (options?.maxStack !== undefined) {
entity.add(new Stackable(options.maxStack));
}
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;
}
}

View File

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

View File

@ -8,10 +8,6 @@ interface Var {
value: RPGVariables[string];
}
interface VariablesState {
vars: RPGVariables;
}
/**
* 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.
@ -20,34 +16,32 @@ interface VariablesState {
* (e.g. health, stats, slot definitions).
*/
@component
export class Variables extends Component<VariablesState> {
export class Variables extends Component<RPGVariables> {
constructor() {
super({ vars: {} });
super({});
}
override getVariables() {
return this.state.vars;
return this.state;
}
@action
set({ key, value }: Var) {
const prev = this.state.vars[key];
this.state.vars[key] = value;
const prev = this.state[key];
this.state[key] = value;
this.emit('set', { key, value, prev });
return this.state.vars;
}
@action
unset(key: string) {
const prev = this.state.vars[key];
delete this.state.vars[key];
const prev = this.state[key];
delete this.state[key];
this.emit('unset', { key, prev });
return this.state.vars;
}
@action
increment({ key, value }: Var) {
const currentValue = this.state.vars[key] ?? 0;
update({ key, value }: Var) {
const currentValue = this.state[key] ?? 0;
if (typeof currentValue === 'number' && typeof value === 'number') {
this.set({ key, value: currentValue + value });
} else {

View File

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

View File

@ -1,6 +1,7 @@
import type { RPGAction, RPGActions, RPGVariables } from "../types";
import { isEvalContext, World } from "../core/world";
import type { EvalContext, Entity } from "../core/world";
import { getComponentName } from "../core/registry";
export function resolveVariables(target: Entity | World): RPGVariables {
const result: RPGVariables = {};
@ -11,7 +12,12 @@ export function resolveVariables(target: Entity | World): RPGVariables {
}
}
} 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())) {
if (value != null) {
if (varKey && varKey !== '.') {
@ -56,7 +62,12 @@ export function resolveActions(target: Entity | World): RPGActions {
}
}
} 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())) {
result[`${key}.${actionKey}`] = fn;
}

View File

@ -1,41 +1,57 @@
import { World } from "@common/rpg/core/world";
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 { QuestLog } from "@common/rpg/components/questLog";
import { QuestSystem } from "@common/rpg/systems/quest";
import { Items } from "@common/rpg/components/item";
import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables";
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() {
const world = new World();
world.addSystem(new QuestSystem());
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');
player.add('inventory', new Inventory(['head', 'legs']));
player.add('health', new Health({value: 100, max: 100}));
player.add('vars', new Variables());
player.add('quests', new QuestLog([{
const inventory = player.add(new Inventory());
player.add(new Equipment('head', { slotName: 'feet', type: 'feet' }));
player.add(new Health({ value: 100, max: 100 }));
player.add(new Variables());
player.add(new QuestLog([{
id: 'test',
description: 'Test quest',
title: 'Test',
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));
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)!;
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(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', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(5));
expect(e.get(Cooldown, 'cd')!.ready).toBeFalse();
e.add(new Cooldown(5));
expect(e.get(Cooldown)!.ready).toBeFalse();
});
it('remaining equals duration on creation', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(3));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(3));
const cd = e.get(Cooldown)!;
expect(cd.state.remaining).toBe(3);
expect(cd.state.duration).toBe(3);
});
@ -31,8 +31,8 @@ describe('Cooldown — clear()', () => {
it('marks as ready immediately', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(10));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(10));
const cd = e.get(Cooldown)!;
cd.clear();
expect(cd.ready).toBeTrue();
});
@ -40,21 +40,21 @@ describe('Cooldown — clear()', () => {
it("emits 'ready' event", () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(10));
e.add(new Cooldown(10));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.get(Cooldown, 'cd')!.clear();
e.on('Cooldown.ready', () => events.push(true));
e.get(Cooldown)!.clear();
expect(events.length).toBe(1);
});
it('is no-op when already ready', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(1));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(1));
const cd = e.get(Cooldown)!;
cd.clear();
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.on('Cooldown.ready', () => events.push(true));
cd.clear();
expect(events.length).toBe(0);
});
@ -64,8 +64,8 @@ describe('Cooldown — reset()', () => {
it('resets remaining to duration', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(5));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(5));
const cd = e.get(Cooldown)!;
cd.clear();
cd.reset();
expect(cd.ready).toBeFalse();
@ -75,8 +75,8 @@ describe('Cooldown — reset()', () => {
it('reset(duration) changes duration and remaining', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(5));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(5));
const cd = e.get(Cooldown)!;
cd.reset(10);
expect(cd.state.duration).toBe(10);
expect(cd.state.remaining).toBe(10);
@ -87,8 +87,8 @@ describe('Cooldown — update(dt)', () => {
it('counts down remaining', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(5));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(5));
const cd = e.get(Cooldown)!;
cd.update(2);
expect(cd.state.remaining).toBe(3);
expect(cd.ready).toBeFalse();
@ -97,8 +97,8 @@ describe('Cooldown — update(dt)', () => {
it('becomes ready when remaining reaches zero', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(3));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(3));
const cd = e.get(Cooldown)!;
cd.update(3);
expect(cd.ready).toBeTrue();
});
@ -106,18 +106,18 @@ describe('Cooldown — update(dt)', () => {
it("emits 'ready' when countdown completes", () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
e.add(new Cooldown(2));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.get(Cooldown, 'cd')!.update(2);
e.on('Cooldown.ready', () => events.push(true));
e.get(Cooldown)!.update(2);
expect(events.length).toBe(1);
});
it('does not go below zero', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(1));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(1));
const cd = e.get(Cooldown)!;
cd.update(100);
expect(cd.state.remaining).toBe(0);
});
@ -125,11 +125,11 @@ describe('Cooldown — update(dt)', () => {
it('is no-op when already ready', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(1));
const cd = e.get(Cooldown, 'cd')!;
e.add(new Cooldown(1));
const cd = e.get(Cooldown)!;
cd.clear();
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.on('Cooldown.ready', () => events.push(true));
cd.update(1);
expect(events.length).toBe(0);
});
@ -139,31 +139,31 @@ describe('CooldownSystem', () => {
it('drives all cooldowns each tick', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
e.add(new Cooldown(2));
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', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
e.add(new Cooldown(2));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.on('Cooldown.ready', () => events.push(true));
w.update(1);
w.update(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', () => {
const w = world();
const a = w.createEntity('a');
const b = w.createEntity('b');
a.add('cd', new Cooldown(1));
b.add('cd', new Cooldown(3));
a.add(new Cooldown(1));
b.add(new Cooldown(3));
w.update(2);
expect(a.get(Cooldown, 'cd')!.ready).toBeTrue();
expect(b.get(Cooldown, 'cd')!.ready).toBeFalse();
expect(a.get(Cooldown)!.ready).toBeTrue();
expect(b.get(Cooldown)!.ready).toBeFalse();
});
});

View File

@ -69,9 +69,9 @@ describe('Effect — duration', () => {
it('emits expired before removal', () => {
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[] = [];
e.on('fx.expired', () => events.push('expired'));
e.on('Effect.expired', () => events.push('expired'));
w.update(1);
expect(events).toEqual(['expired']);
});

View File

@ -12,10 +12,10 @@ function makeSword(w: World, id = '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');
player.add('str', new Stat({ value: 10 }));
player.add('equipment', new Equipment(slots));
player.add(new Equipment(slots));
return player;
}
@ -61,7 +61,7 @@ describe('Equipment — equip', () => {
const w = world();
makeSword(w);
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();
});
@ -81,7 +81,7 @@ describe('Equipment — equip', () => {
makeSword(w);
const player = makePlayer(w);
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' });
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
});
@ -165,7 +165,7 @@ describe('Equipment — unequip', () => {
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' });
const events: unknown[] = [];
player.on('equipment.unequip', ({ data }) => events.push(data));
player.on('Equipment.unequip', ({ data }) => events.push(data));
eq.unequip('weapon');
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
});
@ -177,10 +177,10 @@ describe('Equipment — queries', () => {
makeSword(w, 'sword1');
makeSword(w, 'sword2');
const player = w.createEntity('player');
player.add('equipment', new Equipment([
player.add('equipment', new Equipment(
{ slotName: 'main', type: 'weapon' },
{ slotName: 'off', type: 'weapon' },
]));
));
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'main', itemId: 'sword1' });
eq.equip({ slotName: 'off', itemId: 'sword2' });
@ -192,10 +192,10 @@ describe('Equipment — queries', () => {
it('findCompatibleSlot prefers typed slot over generic', () => {
const w = world();
const player = w.createEntity('player');
player.add('equipment', new Equipment([
player.add('equipment', new Equipment(
'generic',
{ slotName: 'weapon', type: 'weapon' },
]));
));
const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBe('weapon');
});
@ -203,7 +203,7 @@ describe('Equipment — queries', () => {
it('findCompatibleSlot falls back to generic slot', () => {
const w = world();
const player = w.createEntity('player');
player.add('equipment', new Equipment(['generic']));
player.add('equipment', new Equipment('generic'));
const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBe('generic');
});
@ -211,7 +211,7 @@ describe('Equipment — queries', () => {
it('findCompatibleSlot returns null when no compatible slot', () => {
const w = world();
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)!;
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", () => {
const { e, xp } = withXp([100]);
const events: unknown[] = [];
e.on('xp.levelup', ({ data }) => events.push(data));
e.on('Experience(xp).levelup', ({ data }) => events.push(data));
xp.award(100);
expect(events).toEqual([{ level: 2, prev: 1 }]);
});
@ -62,7 +62,7 @@ describe('Experience — award XP (array spec)', () => {
it("emits 'levelup' for each level gained", () => {
const { e, xp } = withXp([100, 200]);
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
expect(events.length).toBe(2);
expect((events[0] as any).level).toBe(2);

View File

@ -39,7 +39,7 @@ describe('FactionMember', () => {
const w = world();
const e = w.createEntity('player');
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 e = w.createEntity();
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);
expect(comp.score).toBe(40);
});
@ -94,7 +94,7 @@ describe('Reputation', () => {
const w = world();
const e = w.createEntity('player');
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('inv', new Inventory());
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)!.use({ itemId: 'potion' });

View File

@ -22,7 +22,7 @@ function simpleQuest(id = 'q1', actions: Quest['stages'][0]['actions'] = []): Qu
stages: [{
id: 'stage0',
description: 'Do the thing',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }],
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
actions,
}],
};
@ -38,13 +38,13 @@ function twoStageQuest(id = 'q2'): Quest {
{
id: 'stage0',
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: [],
},
{
id: 'stage1',
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: [],
},
],
@ -54,7 +54,7 @@ function twoStageQuest(id = 'q2'): Quest {
function makePlayer(w: World, quests: Quest[] = []) {
const player = w.createEntity('player');
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 };
}
@ -170,7 +170,7 @@ describe('QuestLog — events', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.started', ({ data }) => events.push(data));
player.on('QuestLog.started', ({ data }) => events.push(data));
questLog.start('q1');
expect(events).toEqual([{ questId: 'q1' }]);
});
@ -179,7 +179,7 @@ describe('QuestLog — events', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.completed', ({ data }) => events.push(data));
player.on('QuestLog.completed', ({ data }) => events.push(data));
questLog.start('q1');
questLog.complete('q1');
expect(events).toEqual([{ questId: 'q1' }]);
@ -189,7 +189,7 @@ describe('QuestLog — events', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.failed', ({ data }) => events.push(data));
player.on('QuestLog.failed', ({ data }) => events.push(data));
questLog.start('q1');
questLog.fail('q1');
expect(events).toEqual([{ questId: 'q1' }]);
@ -199,7 +199,7 @@ describe('QuestLog — events', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.abandoned', ({ data }) => events.push(data));
player.on('QuestLog.abandoned', ({ data }) => events.push(data));
questLog.start('q1');
questLog.abandon('q1');
expect(events).toEqual([{ questId: 'q1' }]);
@ -254,14 +254,14 @@ describe('QuestLog — availability', () => {
it('quest with unsatisfied condition is not available', () => {
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]);
expect(questLog.isAvailable('q1', player.context)).toBeFalse();
});
it('quest with satisfied condition is available', () => {
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]);
vars.set({ key: 'unlocked', value: true });
expect(questLog.isAvailable('q1', player.context)).toBeTrue();
@ -315,7 +315,7 @@ describe('QuestLog — _advance', () => {
const w = world();
const { player, questLog } = makePlayer(w, [twoStageQuest()]);
const events: unknown[] = [];
player.on('questLog.stage', ({ data }) => events.push(data));
player.on('QuestLog.stage', ({ data }) => events.push(data));
questLog.start('q2');
questLog._advance('q2');
expect((events[0] as any).questId).toBe('q2');
@ -334,7 +334,7 @@ describe('QuestLog — _advance', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.completed', ({ data }) => events.push(data));
player.on('QuestLog.completed', ({ data }) => events.push(data));
questLog.start('q1');
questLog._advance('q1');
expect(events).toEqual([{ questId: 'q1' }]);
@ -357,8 +357,8 @@ describe('Quests.validate', () => {
});
it('passes when action type is in the known actions list', () => {
const quest = simpleQuest('q', [{ type: 'vars.set' }]);
const errors = Quests.validate(quest, ['vars.set']);
const quest = simpleQuest('q', [{ type: 'Variables(vars).set' }]);
const errors = Quests.validate(quest, ['Variables(vars).set']);
expect(errors).toHaveLength(0);
});
@ -405,7 +405,7 @@ describe('QuestSystem — objective completion', () => {
it('runs stage actions before advancing', () => {
const w = world();
// 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]);
questLog.start('q1');
@ -413,7 +413,7 @@ describe('QuestSystem — objective completion', () => {
w.update(1);
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: [{
id: 'stage0',
description: 'Do it',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }],
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
actions: [],
failConditions: ['vars.failed == true'],
failConditions: ['Variables(vars).failed == true'],
}],
};
const { vars, questLog } = makePlayer(w, [quest]);
@ -485,9 +485,9 @@ describe('QuestSystem — fail conditions', () => {
stages: [{
id: 'stage0',
description: 'Both',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }],
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
actions: [],
failConditions: ['vars.done == true'], // same condition
failConditions: ['Variables(vars).done == true'], // same condition
}],
};
const { vars, questLog } = makePlayer(w, [quest]);
@ -508,11 +508,11 @@ describe('QuestSystem — multiple quests', () => {
const q1: Quest = {
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 = {
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]));

View File

@ -116,7 +116,7 @@ describe('Stat — value / base / modifiers', () => {
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
const events: unknown[] = [];
e.on('s.set', ({ data }) => events.push(data));
e.on('Stat(s).set', ({ data }) => events.push(data));
s.set(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 }));
const s = e.get(Stat, 's')!;
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(-200); // still 0, no change
expect(events.length).toBe(1);
@ -150,7 +150,7 @@ describe('Health', () => {
e.add('health', new Health({ value: 10, min: 0 }));
const h = e.get(Health)!;
const killed: unknown[] = [];
e.on('health.killed', () => killed.push(true));
e.on('Health(health).killed', () => killed.push(true));
h.update(-10);
expect(killed.length).toBe(1);
expect(h.value).toBe(0);
@ -162,7 +162,7 @@ describe('Health', () => {
e.add('health', new Health({ value: 50, min: 0 }));
const h = e.get(Health)!;
const killed: unknown[] = [];
e.on('health.killed', () => killed.push(true));
e.on('Health(health).killed', () => killed.push(true));
h.kill();
expect(killed.length).toBe(1);
expect(h.value).toBe(0);
@ -174,7 +174,19 @@ describe('Health', () => {
e.add('health', new Health({ value: 10, min: 0 }));
const h = e.get(Health)!;
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);
expect(killed.length).toBe(1);
});