1
0
Fork 0
This commit is contained in:
Pabloader 2026-04-29 16:01:52 +00:00
parent 693b078144
commit 6d5cd0b2cc
22 changed files with 2396 additions and 83 deletions

View File

@ -9,18 +9,15 @@ definition registry (neutral/friendly/hostile thresholds) pairs with this.
--- ---
## Missing Systems ## Deferred Combat Features
### `CombatSystem` (`systems/combatSystem.ts`) ### Effect stacking rules
Resolves attack attempts between entities. Needs a lightweight `Attack` marker Add `stack` (default) / `unique` / `replace` modes with a tag discriminator to
component (attacker entity ID, target entity ID, damage, damage type) that is added `Effect`. Prevents e.g. multiple poison stacks when the design calls for one.
to an entity to queue an attack for the next tick. Each tick the system:
1. Processes queued `Attack` components.
2. Applies damage to the target's `Health` stat (accounting for any defense modifier
Effects on the target).
3. Removes the `Attack` component after resolution.
4. Emits `'hit'` / `'kill'` events on the target entity as appropriate.
Keeping the attack as a component (rather than a direct method call) lets other ### Damage modifiers
systems react before resolution (parry window, shield-block effects, etc.). Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
the attacker/source that CombatSystem folds into the final damage value.
### Crit / variance
RNG layer on top of damage calculation (crit chance, crit multiplier, random range).

View File

@ -0,0 +1,12 @@
import { component } from "../core/registry";
import { Component } from "../core/world";
import { Stat } from "./stat";
@component
export class Defense extends Stat<{ damageType: string }> { }
@component
export class Damage extends Stat<{ damageType: string, minDamage?: number }> { }
@component
export class Attacked extends Component<{ attackerId: string; sourceId: string | null }> { }

View File

@ -12,6 +12,7 @@ export class Effect extends Component<{
duration: number | null; // null = permanent until removed duration: number | null; // null = permanent until removed
remaining: number | null; // countdown in seconds; null for condition-based/permanent remaining: number | null; // countdown in seconds; null for condition-based/permanent
condition: string | null; // keep effect while true; remove when it becomes false condition: string | null; // keep effect while true; remove when it becomes false
scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target
}> { }> {
/** True while the effect's delta is applied to the target stat. */ /** True while the effect's delta is applied to the target stat. */
@variable('.') active: boolean = false; @variable('.') active: boolean = false;
@ -22,6 +23,7 @@ export class Effect extends Component<{
targetField?: 'value' | 'max' | 'min', targetField?: 'value' | 'max' | 'min',
duration?: number, duration?: number,
condition?: string, condition?: string,
scope?: 'equip' | 'onHit',
) { ) {
super({ super({
targetStat, targetStat,
@ -30,10 +32,12 @@ export class Effect extends Component<{
duration: duration ?? null, duration: duration ?? null,
remaining: duration ?? null, remaining: duration ?? null,
condition: condition ?? null, condition: condition ?? null,
scope: scope ?? 'equip',
}); });
} }
override onAdd(): void { override onAdd(): void {
if (this.state.scope === 'onHit') return;
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);
@ -42,6 +46,7 @@ export class Effect extends Component<{
} }
override onRemove(): void { override onRemove(): void {
if (this.state.scope === 'onHit') return;
const stat = this.entity.get(Stat, this.state.targetStat); const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) { if (stat) {
stat.removeModifier(this.state.delta, this.state.targetField); stat.removeModifier(this.state.delta, this.state.targetField);
@ -66,7 +71,8 @@ export class Effect extends Component<{
this.emit('expired'); this.emit('expired');
} }
update(dt: number, ctx?: EvalContext): void { update(dt: number, ctx: EvalContext): void {
if (this.state.scope === 'onHit') return;
if (this.state.remaining != null) { if (this.state.remaining != null) {
this.state.remaining -= dt; this.state.remaining -= dt;
if (this.state.remaining <= 0) { if (this.state.remaining <= 0) {
@ -74,7 +80,7 @@ export class Effect extends Component<{
this.clear(); this.clear();
} }
} else if (this.state.condition != null) { } else if (this.state.condition != null) {
if (!evaluateCondition(this.state.condition, ctx ?? this.context)) { if (!evaluateCondition(this.state.condition, ctx)) {
this.clear(); this.clear();
} }
} }

View File

@ -1,8 +1,9 @@
import { component } from "../core/registry"; import { component } from "../core/registry";
import { Component, COMPONENT_STATE } from "../core/world"; import { Component } from "../core/world";
import { action } from "../utils/decorators"; import { action } from "../utils/decorators";
import type { RPGVariables } from "../types"; import type { RPGVariables } from "../types";
import { Effect } from "./effect"; import { Effect } from "./effect";
import { Damage } from "./combat";
// ── Equippable ──────────────────────────────────────────────────────────────── // ── Equippable ────────────────────────────────────────────────────────────────
@ -111,13 +112,12 @@ export class Equipment extends Component<EquipmentState> {
slot.itemId = itemId; slot.itemId = itemId;
this.#cachedVars = null; this.#cachedVars = null;
let id = 0;
for (const [key, component] of itemEntity) { for (const [key, component] of itemEntity) {
if (!(component instanceof Effect)) continue; if (!(component instanceof Effect) || component.state.scope === 'onHit') continue;
const clone = Object.create(Effect.prototype) as Effect;
(clone as unknown as { state: unknown }).state = const effectKey = `__equip_${slotName}_${key}_${id++}`;
structuredClone(component[COMPONENT_STATE]()); this.entity.clone(effectKey, component);
const effectKey = `__equip_${slotName}_${key}`;
this.entity.add(effectKey, clone);
slot.appliedEffectKeys.push(effectKey); slot.appliedEffectKeys.push(effectKey);
} }

View File

@ -18,6 +18,28 @@ interface InventoryState {
slots: SlotRecord[]; slots: SlotRecord[];
} }
function buildInventoryState(input?: number | InventorySlotInput[]): InventoryState {
if (input === undefined) {
return { infinite: true, nextSlotId: 0, slots: [] };
}
if (typeof input === 'number') {
return {
infinite: false,
nextSlotId: 0,
slots: Array.from({ length: input }, (_, i) => ({ slotId: i, limit: undefined, contents: null })),
};
}
return {
infinite: false,
nextSlotId: 0,
slots: input.map(def => ({
slotId: typeof def === 'object' ? def.slotId : def,
limit: typeof def === 'object' ? def.limit : undefined,
contents: null,
})),
};
}
@component @component
export class Inventory extends Component<InventoryState> { export class Inventory extends Component<InventoryState> {
#cachedVars: RPGVariables | null = null; #cachedVars: RPGVariables | null = null;
@ -29,27 +51,7 @@ export class Inventory extends Component<InventoryState> {
/** Named slots with optional per-slot stack limits (original behaviour). */ /** Named slots with optional per-slot stack limits (original behaviour). */
constructor(slots: InventorySlotInput[]); constructor(slots: InventorySlotInput[]);
constructor(input?: number | InventorySlotInput[]) { constructor(input?: number | InventorySlotInput[]) {
if (input === undefined) { super(buildInventoryState(input));
super({ infinite: true, nextSlotId: 0, slots: [] });
} else if (typeof input === 'number') {
super({
infinite: false,
nextSlotId: 0,
slots: Array.from({ length: input }, (_, i) => ({
slotId: i, limit: undefined, contents: null,
})),
});
} else {
super({
infinite: false,
nextSlotId: 0,
slots: input.map(def => {
const slotId = typeof def === 'object' ? def.slotId : def;
const limit = typeof def === 'object' ? def.limit : undefined;
return { slotId, limit, contents: null };
}),
});
}
} }
#slot(slotId: SlotId): SlotRecord | undefined { #slot(slotId: SlotId): SlotRecord | undefined {

View File

@ -5,14 +5,18 @@ import { component } from "../core/registry";
interface StatState { interface StatState {
base: number; base: number;
modifierSums: { value: number; max: number; min: number }; modifierSums: { value: number; max: number; min: number };
max: number | undefined; max?: number;
min: number | undefined; min?: number;
} }
@component @component
export class Stat extends Component<StatState> { export class Stat<T = {}> extends Component<StatState & T> {
constructor(value: number, max?: number, min?: number) { constructor(args: Omit<StatState, 'modifierSums' | 'base'> & T & { value: number }) {
super({ base: value, modifierSums: { value: 0, max: 0, min: 0 }, max, min }); super({
base: args.value,
modifierSums: { value: 0, max: 0, min: 0 },
...args,
});
} }
@variable('.') get value(): number { @variable('.') get value(): number {
@ -68,11 +72,13 @@ export class Health extends Stat {
this.set(0); this.set(0);
this.emit('killed'); this.emit('killed');
} }
@action
override update(amount: number) {
super.update(amount);
if (this.value <= 0) {
this.kill();
}
}
} }
@component
export class Defense extends Stat { }
@component
export class Damage extends Stat { }

View File

@ -1,4 +1,4 @@
import { World, Entity, Component, COMPONENT_STATE, WORLD_ENTITY_COUNTER } from './world'; import { World, Entity, Component, WORLD_ENTITY_COUNTER } from './world';
import { getComponentMeta, getComponentName, migrateState } from './registry'; import { getComponentMeta, getComponentName, migrateState } from './registry';
/** Increment this when the WorldData/EntityData structure itself changes incompatibly. */ /** Increment this when the WorldData/EntityData structure itself changes incompatibly. */
@ -37,7 +37,7 @@ 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[COMPONENT_STATE]() }; return { type: 'component', name, key: component.key, version: meta.version, state: component.state };
} }
function serializeEntity(entity: Entity): EntityData { function serializeEntity(entity: Entity): EntityData {

View File

@ -19,9 +19,6 @@ export interface EvalContext {
world: World; world: World;
} }
/** Symbol used by Serialization to read component state. */
export const COMPONENT_STATE = Symbol('rpg.component.state');
/** 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');
@ -29,13 +26,14 @@ export abstract class Component<TState = Record<string, unknown>> {
entity!: Entity; entity!: Entity;
key!: string; key!: string;
protected state: TState; private _state!: TState;
constructor(state: TState) { constructor(state: TState) {
this.state = state; this._state = state;
} }
[COMPONENT_STATE](): TState { return this.state; } get state(): TState { return this._state; }
protected set state(state: TState) { this._state = state; }
protected emit(event: string, data?: unknown): void { protected emit(event: string, data?: unknown): void {
this.entity.emit(`${this.key}.${event}`, data); this.entity.emit(`${this.key}.${event}`, data);
@ -80,9 +78,11 @@ export abstract class Component<TState = Record<string, unknown>> {
export abstract class System { export abstract class System {
onAdd(_world: World): void { } onAdd(_world: World): void { }
onRemove(_world: World): void { } onRemove(_world: World): void { }
update(_world: World, _dt: number): void { }; async update(_world: World, _dt: number): Promise<void> { };
} }
type ComponentFilter<T> = (component: T) => boolean;
export class Entity { export class Entity {
readonly #components = new Map<string, Component>(); readonly #components = new Map<string, Component>();
@ -105,23 +105,41 @@ export class Entity {
return component; return component;
} }
clone<T extends Component<any>>(key: string, component: T): T {
const clone = Object.create(component.constructor.prototype) as T;
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
return this.add(key, clone);
}
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>): 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>>(ctorOrKey: Class<T> | string, key?: string): 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 {
if (typeof ctorOrKey === 'string') { if (typeof ctorOrKey === 'string') {
return this.#components.get(ctorOrKey) as T | undefined; return this.#components.get(ctorOrKey) as T | undefined;
} }
if (key !== undefined) { if (typeof key === 'string') {
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;
} }
for (const c of this.#components.values()) { for (const c of this.#components.values()) {
if (c instanceof ctorOrKey) return c as T; if (!(c instanceof ctorOrKey)) continue;
if (typeof key === 'function' && !key(c)) continue;
return c as T;
} }
return undefined; return undefined;
} }
getAll<T extends Component<any>>(ctor: Class<T>): T[] {
const result: T[] = [];
for (const c of this.#components.values()) {
if (c instanceof ctor) result.push(c as T);
}
return result;
}
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;
@ -136,12 +154,17 @@ export class Entity {
remove(key: string): void; remove(key: string): void;
remove<T extends Component<any>>(ctor: Class<T>): 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>, key: string): void; remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
remove<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): void { remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string): void {
if (typeof ctorOrKey === 'string') { if (typeof ctorOrKey === 'string') {
this.#removeByKey(ctorOrKey); this.#removeByKey(ctorOrKey);
return; return;
} }
if (ctorOrKey instanceof Component) {
this.#removeByKey(ctorOrKey.key);
return;
}
if (key !== undefined) { if (key !== undefined) {
if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key); if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key);
return; return;
@ -254,7 +277,7 @@ export class World {
const target = this.createEntity(newId); const target = this.createEntity(newId);
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[COMPONENT_STATE]()); (clone as unknown as { state: unknown }).state = structuredClone(component.state);
target.add(key, clone); target.add(key, clone);
} }
return target; return target;
@ -293,8 +316,8 @@ export class World {
} }
} }
update(dt: number): void { async update(dt: number): Promise<void> {
for (const system of this.#systems) system.update(this, dt); for (const system of this.#systems) await system.update(this, dt);
} }
emit(entityId: string, event: string, data?: unknown): void { emit(entityId: string, event: string, data?: unknown): void {

View File

@ -0,0 +1,81 @@
import { Attacked, Damage, Defense } from "../components/combat";
import { Effect } from "../components/effect";
import { Health } from "../components/stat";
import { System, World } from "../core/world";
let hitEffectCounter = 0;
export class CombatSystem extends System {
override async update(world: World): Promise<void> {
for (const [target] of world.query(Attacked)) {
const health = target.get(Health);
if (!health) {
console.warn(`[CombatSystem] Target ${target.id} has no Health component`);
for (const attack of target.getAll(Attacked)) target.remove(attack);
continue;
}
let damageSum = 0;
const hitEvents: { attackerId: string; sourceId: string | null; amount: number; damageType: string }[] = [];
let lastHit: { attackerId: string; sourceId: string | null } | null = null;
for (const attack of target.getAll(Attacked)) {
const { attackerId, sourceId } = attack.state;
target.remove(attack);
const attacker = world.getEntity(attackerId);
if (!attacker) {
console.warn(`[CombatSystem] Attacker ${attackerId} not found`);
continue;
}
const source = sourceId ? world.getEntity(sourceId) : attacker;
if (!source) {
console.warn(`[CombatSystem] Source ${sourceId} not found`);
continue;
}
const damage = source.get(Damage);
if (!damage) {
console.warn(`[CombatSystem] No Damage on source ${source.id}`);
continue;
}
const { damageType, minDamage = 0 } = damage.state;
let damageAmount = damage.value;
const defense = target.get(Defense, (c) => c.state.damageType === damageType);
if (defense) {
damageAmount = Math.max(minDamage, damageAmount - defense.value);
}
// Apply on-hit effects from source onto target
for (const [key, component] of source) {
if (!(component instanceof Effect) || component.state.scope !== 'onHit') continue;
const s = component.state;
target.add(
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
new Effect(s.targetStat, s.delta, s.targetField, s.duration ?? undefined, s.condition ?? undefined),
);
}
damageSum += damageAmount;
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
lastHit = { attackerId, sourceId };
}
if (damageSum === 0) continue;
const wasAlive = health.value > 0;
health.update(-damageSum);
for (const info of hitEvents) {
target.emit('hit', info);
}
if (wasAlive && health.value <= 0 && lastHit) {
target.emit('kill', lastHit);
}
}
}
}

View File

@ -2,7 +2,7 @@ import { Cooldown } from "../components/cooldown";
import { System, type World } from "../core/world"; import { System, type World } from "../core/world";
export class CooldownSystem extends System { export class CooldownSystem extends System {
override update(world: World, dt: number): void { override async update(world: World, dt: number): Promise<void> {
for (const [, , cooldown] of world.query(Cooldown)) { for (const [, , cooldown] of world.query(Cooldown)) {
cooldown.update(dt); cooldown.update(dt);
} }

View File

@ -2,12 +2,12 @@ import { Effect } from "../components/effect";
import { System, type Entity, type World } from "../core/world"; import { System, type Entity, type World } from "../core/world";
export class EffectSystem extends System { export class EffectSystem extends System {
override update(world: World, dt: number): void { override async update(world: World, dt: number): Promise<void> {
const expired: [Entity, string][] = []; const expired: [Entity, string][] = [];
for (const [entity, key, effect] of world.query(Effect)) { for (const [entity, key, effect] of world.query(Effect)) {
effect.update(dt, entity.context); effect.update(dt, entity.context);
if (!effect.active) { if (!effect.active && effect.state.scope !== 'onHit') {
expired.push([entity, key]); expired.push([entity, key]);
} }
} }

View File

@ -27,12 +27,12 @@ export class QuestSystem extends System {
this.#tracking.clear(); this.#tracking.clear();
} }
override update(world: World, _dt: number): void { override async update(world: World, _dt: number): Promise<void> {
for (const [entity, key, questLog] of world.query(QuestLog)) { for (const [entity, key, questLog] of world.query(QuestLog)) {
if (!this.#tracking.has(entity.id)) { if (!this.#tracking.has(entity.id)) {
this.#initTracking(entity, key, questLog); this.#initTracking(entity, key, questLog);
} }
void this.#diffAndCheck(entity, world); await this.#diffAndCheck(entity, world);
} }
// Prune tracking for entities that no longer exist // Prune tracking for entities that no longer exist
@ -68,7 +68,7 @@ export class QuestSystem extends System {
} }
// Keep tracking fresh as quest state changes // Keep tracking fresh as quest state changes
const onStarted = ({ data }: { data?: unknown }) => { const onStarted = async ({ data }: { data?: unknown }) => {
const { questId } = data as { questId: string }; const { questId } = data as { questId: string };
const quest = questLog.getQuest(questId); const quest = questLog.getQuest(questId);
const state = questLog.getState(questId); const state = questLog.getState(questId);
@ -76,15 +76,15 @@ export class QuestSystem extends System {
if (stage) { if (stage) {
this.#addQuestVars(tracking, questId, stage); this.#addQuestVars(tracking, questId, stage);
// Evaluate immediately — conditions may already be satisfied at start // Evaluate immediately — conditions may already be satisfied at start
void this.#checkEntity(entity, entity.world, new Set([questId])); await this.#checkEntity(entity, entity.world, new Set([questId]));
} }
}; };
const onStage = ({ data }: { data?: unknown }) => { const onStage = async ({ data }: { data?: unknown }) => {
const { questId, stage } = data as { questId: string; stage: QuestStage }; const { questId, stage } = data as { questId: string; stage: QuestStage };
this.#removeQuestVars(tracking, questId); this.#removeQuestVars(tracking, questId);
this.#addQuestVars(tracking, questId, stage); this.#addQuestVars(tracking, questId, stage);
void this.#checkEntity(entity, entity.world, new Set([questId])); await this.#checkEntity(entity, entity.world, new Set([questId]));
}; };
const onDone = ({ data }: { data?: unknown }) => { const onDone = ({ data }: { data?: unknown }) => {

View File

@ -3,7 +3,7 @@ import { Inventory } from "@common/rpg/components/inventory";
import { Health } from "@common/rpg/components/stat"; import { Health } 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/questSystem"; 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";
@ -17,7 +17,7 @@ export default async function main() {
const player = world.createEntity('player'); const player = world.createEntity('player');
player.add('inventory', new Inventory(['head', 'legs'])); player.add('inventory', new Inventory(['head', 'legs']));
player.add('health', new Health(100, 100)); player.add('health', new Health({value: 100, max: 100}));
player.add('vars', new Variables()); player.add('vars', new Variables());
player.add('quests', new QuestLog([{ player.add('quests', new QuestLog([{
id: 'test', id: 'test',
@ -28,13 +28,11 @@ export default async function main() {
console.log(resolveVariables(world)); console.log(resolveVariables(world));
const inventory = player.get(Inventory)!; player.get(Inventory)?.add({ itemId: 'helmet', amount: 1, slotId: 'head' });
inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' });
const vars = player.get(Variables)!; const vars = player.get(Variables)!;
vars.set({ key: 'test', value: 'test' }); vars.set({ key: 'test', value: 'test' });
await executeAction({ type: 'inventory.add', arg: { itemId: 'boots', amount: 2 } }, player);
await executeAction('player.quests.test.start', world); await executeAction('player.quests.test.start', world);
console.log(resolveActions(world)); console.log(resolveActions(world));

View File

@ -0,0 +1,247 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Health, Stat } from '@common/rpg/components/stat';
import { Damage, Defense, Attacked } from '@common/rpg/components/combat';
import { Effect } from '@common/rpg/components/effect';
import { CombatSystem } from '@common/rpg/systems/combat';
import { EffectSystem } from '@common/rpg/systems/effect';
function world() {
const w = new World();
w.addSystem(new CombatSystem());
w.addSystem(new EffectSystem());
return w;
}
describe('CombatSystem — damage', () => {
it('reduces target health by damage value', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(80);
});
it('defense on target reduces damage', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 8, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(88);
});
it('defense only applies to matching damage type', async () => {
const w = world();
const spell = w.createEntity('spell');
spell.add('dmg', new Damage({ value: 20, damageType: 'fire' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 8, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'spell' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(80);
});
it('minDamage is enforced when defense exceeds damage', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical', minDamage: 3 }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 10, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(97);
});
it('null sourceId falls back to Damage on attacker', async () => {
const w = world();
const attacker = w.createEntity('attacker');
attacker.add('dmg', new Damage({ value: 15, damageType: 'physical' }));
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: null }));
await w.update(1);
expect(target.get(Health)!.value).toBe(85);
});
it('multiple attacks in one tick accumulate', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
w.createEntity('a');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(80);
});
it('Attacked components are removed after processing', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
w.createEntity('a');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
await w.update(1);
expect(target.has(Attacked)).toBeFalse();
});
it('missing attacker entity skips attack gracefully', async () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'ghost', sourceId: null }));
await w.update(1);
expect(target.get(Health)!.value).toBe(100);
});
it('missing source entity skips attack gracefully', async () => {
const w = world();
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'gone' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(100);
});
it('source with no Damage component skips attack gracefully', async () => {
const w = world();
w.createEntity('attacker');
w.createEntity('empty_source');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(100);
});
it('target with no Health component is skipped gracefully', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1); // should not throw
});
});
describe('CombatSystem — on-hit effects', () => {
it('onHit effect is applied to target on hit', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('burn', new Effect('health', -5, 'value', 10, undefined, 'onHit'));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier
expect(target.getAll(Effect).length).toBe(1);
});
it('onHit effect on weapon does not affect weapon itself', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('str', new Stat({ value: 50 }));
sword.add('drain', new Effect('str', -99, 'value', undefined, undefined, 'onHit'));
expect(sword.get(Stat, 'str')!.value).toBe(50);
});
it('onHit effect expires on target after duration', async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
sword.add('burn', new Effect('health', -10, 'value', 2, undefined, 'onHit'));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
const afterHit = target.get(Health)!.value; // 85
await w.update(2); // burn expires
expect(target.getAll(Effect).length).toBe(0);
expect(target.get(Health)!.value).toBe(afterHit + 10); // modifier reverted
});
});
describe('CombatSystem — events', () => {
it("emits 'hit' on target with attack info", async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'fire' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
const hits: unknown[] = [];
target.on('hit', ({ data }) => hits.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(hits.length).toBe(1);
expect((hits[0] as any).damageType).toBe('fire');
expect((hits[0] as any).amount).toBe(10);
expect((hits[0] as any).attackerId).toBe('attacker');
expect((hits[0] as any).sourceId).toBe('sword');
});
it("emits 'kill' when target health reaches zero", async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 999, damageType: 'physical' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 50, min: 0 }));
const kills: unknown[] = [];
target.on('kill', ({ data }) => kills.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(kills.length).toBe(1);
});
it("does not emit 'kill' when target survives", async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
const kills: unknown[] = [];
target.on('kill', ({ data }) => kills.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
expect(kills.length).toBe(0);
});
it("emits 'hit' per attack when multiple attacks land", async () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
w.createEntity('a');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
const hits: unknown[] = [];
target.on('hit', ({ data }) => hits.push(data));
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
await w.update(1);
expect(hits.length).toBe(2);
});
});

View File

@ -0,0 +1,169 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Cooldown } from '@common/rpg/components/cooldown';
import { CooldownSystem } from '@common/rpg/systems/cooldown';
function world() {
const w = new World();
w.addSystem(new CooldownSystem());
return w;
}
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();
});
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')!;
expect(cd.state.remaining).toBe(3);
expect(cd.state.duration).toBe(3);
});
});
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')!;
cd.clear();
expect(cd.ready).toBeTrue();
});
it("emits 'ready' event", () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(10));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.get(Cooldown, 'cd')!.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')!;
cd.clear();
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
cd.clear();
expect(events.length).toBe(0);
});
});
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')!;
cd.clear();
cd.reset();
expect(cd.ready).toBeFalse();
expect(cd.state.remaining).toBe(5);
});
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')!;
cd.reset(10);
expect(cd.state.duration).toBe(10);
expect(cd.state.remaining).toBe(10);
});
});
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')!;
cd.update(2);
expect(cd.state.remaining).toBe(3);
expect(cd.ready).toBeFalse();
});
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')!;
cd.update(3);
expect(cd.ready).toBeTrue();
});
it("emits 'ready' when countdown completes", () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
e.get(Cooldown, 'cd')!.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')!;
cd.update(100);
expect(cd.state.remaining).toBe(0);
});
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')!;
cd.clear();
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
cd.update(1);
expect(events.length).toBe(0);
});
});
describe('CooldownSystem', () => {
it('drives all cooldowns each tick', async () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
await w.update(1);
expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1);
});
it('marks cooldown ready after enough ticks', async () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
await w.update(1);
await w.update(1);
expect(events.length).toBe(1);
expect(e.get(Cooldown, 'cd')!.ready).toBeTrue();
});
it('handles multiple cooldowns on different entities', async () => {
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));
await w.update(2);
expect(a.get(Cooldown, 'cd')!.ready).toBeTrue();
expect(b.get(Cooldown, 'cd')!.ready).toBeFalse();
});
});

View File

@ -0,0 +1,160 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Stat } from '@common/rpg/components/stat';
import { Effect } from '@common/rpg/components/effect';
import { EffectSystem } from '@common/rpg/systems/effect';
function world() {
const w = new World();
w.addSystem(new EffectSystem());
return w;
}
function withStat(value = 10, min?: number, max?: number) {
const w = world();
const e = w.createEntity();
e.add('str', new Stat({ value, min, max }));
return { w, e, stat: e.get(Stat, 'str')! };
}
describe('Effect — onAdd / onRemove', () => {
it('applies delta to stat on add', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 5));
expect(stat.value).toBe(15);
});
it('reverts delta on remove', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 5));
e.remove('fx');
expect(stat.value).toBe(10);
});
it('applies delta to max field', () => {
const { e } = withStat(10, undefined, 20);
const s = e.get(Stat, 'str')!;
e.add('fx', new Effect('str', 10, 'max'));
expect(s.max).toBe(30);
e.remove('fx');
expect(s.max).toBe(20);
});
it('active is true after add', () => {
const { e } = withStat(10);
e.add('fx', new Effect('str', 1));
expect(e.get(Effect, 'fx')!.active).toBeTrue();
});
it('no-op if target stat is missing', () => {
const w = world();
const e = w.createEntity();
expect(() => e.add('fx', new Effect('str', 5))).not.toThrow();
});
});
describe('Effect — duration', () => {
it('expires after duration ticks', async () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2));
expect(stat.value).toBe(15);
await w.update(1);
expect(e.has(Effect)).toBeTrue();
await w.update(1);
expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10);
});
it('emits expired before removal', async () => {
const { w, e } = withStat(10);
e.add('fx', new Effect('str', 1, 'value', 1));
const events: string[] = [];
e.on('fx.expired', () => events.push('expired'));
await w.update(1);
expect(events).toEqual(['expired']);
});
it('reset() restarts timer', async () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2));
await w.update(1.5);
e.get(Effect, 'fx')!.reset();
await w.update(1.5); // would have expired without reset
expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15);
});
it('reset(duration) changes duration', async () => {
const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1));
e.get(Effect, 'fx')!.reset(10);
await w.update(5);
expect(e.has(Effect)).toBeTrue();
});
it('clear() immediately expires effect', async () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 100));
e.get(Effect, 'fx')!.clear();
await w.update(0.01);
expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10);
});
});
describe('Effect — permanent', () => {
it('permanent effect is never removed by EffectSystem', async () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5));
await w.update(100);
expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15);
});
});
describe('Effect — scope: onHit', () => {
it('onAdd is a no-op for onHit scope', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
expect(stat.value).toBe(10);
});
it('onRemove is a no-op for onHit scope', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
e.remove('fx');
expect(stat.value).toBe(10);
});
it('EffectSystem does not tick or remove onHit effects', async () => {
const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit'));
await w.update(10);
expect(e.has(Effect)).toBeTrue();
});
it('active stays false for onHit scope', () => {
const { e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', undefined, undefined, 'onHit'));
expect(e.get(Effect, 'fx')!.active).toBeFalse();
});
});
describe('Effect — multiple effects on same stat', () => {
it('multiple effects stack additively', () => {
const { e, stat } = withStat(10);
e.add('a', new Effect('str', 3));
e.add('b', new Effect('str', 7));
expect(stat.value).toBe(20);
});
it('removing one effect reverts only that delta', () => {
const { e, stat } = withStat(10);
e.add('a', new Effect('str', 3));
e.add('b', new Effect('str', 7));
e.remove('a');
expect(stat.value).toBe(17);
});
});

View File

@ -0,0 +1,218 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Stat } from '@common/rpg/components/stat';
import { Effect } from '@common/rpg/components/effect';
import { Equipment, Equippable } from '@common/rpg/components/equipment';
function world() { return new World(); }
function makeSword(w: World, id = 'sword') {
const sword = w.createEntity(id);
sword.add('equippable', new Equippable('weapon'));
return sword;
}
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));
return player;
}
describe('Equipment — equip', () => {
it('equips item into a typed slot', () => {
const w = world();
makeSword(w);
const player = makePlayer(w);
const result = player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(result).toBeTrue();
expect(player.get(Equipment)!.getItem('weapon')).toBe('sword');
});
it('returns false for unknown slot', () => {
const w = world();
makeSword(w);
const player = makePlayer(w);
expect(player.get(Equipment)!.equip({ slotName: 'head', itemId: 'sword' })).toBeFalse();
});
it('returns false for missing item entity', () => {
const w = world();
const player = makePlayer(w);
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'ghost' })).toBeFalse();
});
it('returns false when item has no Equippable component', () => {
const w = world();
w.createEntity('rock'); // no Equippable
const player = makePlayer(w);
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'rock' })).toBeFalse();
});
it('returns false when slot type does not match Equippable.slotType', () => {
const w = world();
const helmet = w.createEntity('helmet');
helmet.add('equippable', new Equippable('armor'));
const player = makePlayer(w); // slot type = 'weapon'
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'helmet' })).toBeFalse();
});
it('generic slot (no type) accepts any Equippable', () => {
const w = world();
makeSword(w);
const player = w.createEntity('player');
player.add('equipment', new Equipment(['slot1'])); // generic slot
expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue();
});
it('equipping occupied slot auto-unequips first', () => {
const w = world();
makeSword(w, 'sword1');
makeSword(w, 'sword2');
const player = makePlayer(w);
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword1' });
eq.equip({ slotName: 'weapon', itemId: 'sword2' });
expect(eq.getItem('weapon')).toBe('sword2');
});
it("emits 'equip' event", () => {
const w = world();
makeSword(w);
const player = makePlayer(w);
const events: unknown[] = [];
player.on('equipment.equip', ({ data }) => events.push(data));
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
});
});
describe('Equipment — equip-scope effects', () => {
it('clones equip-scope Effect onto owner on equip', () => {
const w = world();
const sword = makeSword(w);
sword.add('bonus', new Effect('str', 5)); // scope: equip (default)
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15);
});
it('does NOT clone onHit-scope Effect onto owner on equip', () => {
const w = world();
const sword = makeSword(w);
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
expect(player.getAll(Effect).length).toBe(0);
});
it('clones multiple equip effects', () => {
const w = world();
const sword = makeSword(w);
sword.add('a', new Effect('str', 3));
sword.add('b', new Effect('str', 7));
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(20);
});
it('equip + onHit: only equip effects reach owner', () => {
const w = world();
const sword = makeSword(w);
sword.add('passive', new Effect('str', 5));
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15);
expect(player.getAll(Effect).length).toBe(1);
});
});
describe('Equipment — unequip', () => {
it('unequip reverts cloned effects', () => {
const w = world();
const sword = makeSword(w);
sword.add('bonus', new Effect('str', 5));
const player = makePlayer(w);
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15);
eq.unequip('weapon');
expect(player.get(Stat, 'str')!.value).toBe(10);
});
it('unequip clears slot itemId', () => {
const w = world();
makeSword(w);
const player = makePlayer(w);
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' });
eq.unequip('weapon');
expect(eq.getItem('weapon')).toBeNull();
});
it('unequip returns false on empty slot', () => {
const w = world();
const player = makePlayer(w);
expect(player.get(Equipment)!.unequip('weapon')).toBeFalse();
});
it("unequip emits 'unequip' event", () => {
const w = world();
makeSword(w);
const player = makePlayer(w);
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' });
const events: unknown[] = [];
player.on('equipment.unequip', ({ data }) => events.push(data));
eq.unequip('weapon');
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
});
});
describe('Equipment — queries', () => {
it('getEquipped returns all filled slots', () => {
const w = world();
makeSword(w, 'sword1');
makeSword(w, 'sword2');
const player = w.createEntity('player');
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' });
const equipped = eq.getEquipped();
expect(equipped.length).toBe(2);
expect(equipped.map(e => e.slotName).sort()).toEqual(['main', 'off']);
});
it('findCompatibleSlot prefers typed slot over generic', () => {
const w = world();
const player = w.createEntity('player');
player.add('equipment', new Equipment([
'generic',
{ slotName: 'weapon', type: 'weapon' },
]));
const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBe('weapon');
});
it('findCompatibleSlot falls back to generic slot', () => {
const w = world();
const player = w.createEntity('player');
player.add('equipment', new Equipment(['generic']));
const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBe('generic');
});
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' }]));
const eq = player.get(Equipment)!;
expect(eq.findCompatibleSlot('weapon')).toBeNull();
});
});

View File

@ -0,0 +1,135 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Experience } from '@common/rpg/components/experience';
function withXp(spec: ConstructorParameters<typeof Experience>[0]) {
const w = new World();
const e = w.createEntity();
e.add('xp', new Experience(spec));
return { e, xp: e.get(Experience, 'xp')! };
}
describe('Experience — initial state', () => {
it('starts at level 1, xp 0', () => {
const { xp } = withXp([100, 200, 400]);
expect(xp.level).toBe(1);
expect(xp.xp).toBe(0);
expect(xp.xpInLevel).toBe(0);
});
it('progress is 0 at start', () => {
const { xp } = withXp([100]);
expect(xp.progress).toBe(0);
});
it('xpToNext equals first threshold', () => {
const { xp } = withXp([100, 300]);
expect(xp.xpToNext).toBe(100);
});
});
describe('Experience — award XP (array spec)', () => {
it('accumulates xp without leveling up', () => {
const { xp } = withXp([100]);
xp.award(50);
expect(xp.xp).toBe(50);
expect(xp.level).toBe(1);
expect(xp.xpInLevel).toBe(50);
});
it('levels up when xp meets threshold', () => {
const { xp } = withXp([100]);
xp.award(100);
expect(xp.level).toBe(2);
expect(xp.xpInLevel).toBe(0);
});
it("emits 'levelup' with prev and new level", () => {
const { e, xp } = withXp([100]);
const events: unknown[] = [];
e.on('xp.levelup', ({ data }) => events.push(data));
xp.award(100);
expect(events).toEqual([{ level: 2, prev: 1 }]);
});
it('handles multiple level-ups in a single award', () => {
const { xp } = withXp([100, 200, 400]);
xp.award(350); // 100 (L1→2) + 200 (L2→3) + 50 leftover
expect(xp.level).toBe(3);
expect(xp.xpInLevel).toBe(50);
});
it("emits 'levelup' for each level gained", () => {
const { e, xp } = withXp([100, 200]);
const events: unknown[] = [];
e.on('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);
expect((events[1] as any).level).toBe(3);
});
it('stops leveling at max level (array spec)', () => {
const { xp } = withXp([100]); // max level = 2
xp.award(9999);
expect(xp.level).toBe(2);
});
it('xpToNext is null at max level', () => {
const { xp } = withXp([100]);
xp.award(9999);
expect(xp.xpToNext).toBeNull();
});
it('progress is 1 at max level', () => {
const { xp } = withXp([100]);
xp.award(9999);
expect(xp.progress).toBe(1);
});
it('xp.total never resets across levels', () => {
const { xp } = withXp([100, 200]);
xp.award(400);
expect(xp.xp).toBe(400);
});
});
describe('Experience — award XP (geometric spec)', () => {
it('levels up using geometric thresholds', () => {
// base=100, factor=2 → L1→L2: 100xp, L2→L3: 200xp
const { xp } = withXp({ base: 100, factor: 2 });
xp.award(100);
expect(xp.level).toBe(2);
xp.award(200);
expect(xp.level).toBe(3);
});
it('has no max level with geometric spec', () => {
const { xp } = withXp({ base: 10, factor: 1 }); // 10xp per level, forever
xp.award(10000);
expect(xp.level).toBeGreaterThan(100);
expect(xp.xpToNext).not.toBeNull();
});
it('progress tracks correctly within a level', () => {
const { xp } = withXp({ base: 100, factor: 2 }); // 100xp for L1→2
xp.award(40);
expect(xp.progress).toBeCloseTo(0.4);
});
});
describe('Experience — xpInLevel / xpToNext', () => {
it('xpInLevel resets after level-up', () => {
const { xp } = withXp([100, 200]);
xp.award(150); // levels up at 100, 50 left
expect(xp.xpInLevel).toBe(50);
expect(xp.xpToNext).toBe(150); // 200 - 50
});
it('xpToNext decreases as xp accumulates', () => {
const { xp } = withXp([100]);
expect(xp.xpToNext).toBe(100);
xp.award(30);
expect(xp.xpToNext).toBe(70);
});
});

View File

@ -0,0 +1,263 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Inventory } from '@common/rpg/components/inventory';
import { Items, Item, Stackable, Usable } from '@common/rpg/components/item';
import { Equipment, Equippable } from '@common/rpg/components/equipment';
import { Stat } from '@common/rpg/components/stat';
function world() { return new World(); }
describe('Items.register', () => {
it('creates entity with Item component', () => {
const w = world();
const e = Items.register(w, 'sword', 'Iron Sword');
expect(e.get(Item)!.name).toBe('Iron Sword');
});
it('adds Stackable when maxStack is provided', () => {
const w = world();
const e = Items.register(w, 'coin', 'Gold Coin', { maxStack: 99 });
expect(e.get(Stackable)!.maxStack).toBe(99);
});
it('does not add Stackable when maxStack is omitted', () => {
const w = world();
const e = Items.register(w, 'key', 'Old Key');
expect(e.has(Stackable)).toBeFalse();
});
it('adds Usable when usable options are provided', () => {
const w = world();
const e = Items.register(w, 'potion', 'Health Potion', {
usable: { actions: [{ type: 'health.update', arg: 50 }] },
});
expect(e.has(Usable)).toBeTrue();
expect(e.get(Usable)!.consumeOnUse).toBeTrue(); // default
});
it('respects consumeOnUse: false', () => {
const w = world();
const e = Items.register(w, 'ring', 'Magic Ring', {
usable: { actions: [], consumeOnUse: false },
});
expect(e.get(Usable)!.consumeOnUse).toBeFalse();
});
it('sets description', () => {
const w = world();
const e = Items.register(w, 'rune', 'Ancient Rune', { description: 'A glowing rune.' });
expect(e.get(Item)!.description).toBe('A glowing rune.');
});
it('registers entity under the given id', () => {
const w = world();
Items.register(w, 'gem', 'Ruby');
expect(w.getEntity('gem')).toBeDefined();
});
});
describe('Inventory — infinite mode', () => {
it('adds items without limit', () => {
const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 99 });
const e = w.createEntity('player');
e.add('inv', new Inventory());
e.get(Inventory)!.add({ itemId: 'coin', amount: 500 });
expect(e.get(Inventory)!.getAmount('coin')).toBe(500);
});
it('grows slot list automatically', () => {
const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 10 });
const e = w.createEntity('player');
e.add('inv', new Inventory());
e.get(Inventory)!.add({ itemId: 'coin', amount: 25 });
// 10 + 10 + 5 = 3 slots
expect(e.get(Inventory)!.getItems().get('coin')).toBe(25);
});
it('add returns true', () => {
const w = world();
Items.register(w, 'gem', 'Gem');
const e = w.createEntity('player');
e.add('inv', new Inventory());
expect(e.get(Inventory)!.add({ itemId: 'gem', amount: 1 })).toBeTrue();
});
it('warns and returns false for unknown item', () => {
const w = world();
const e = w.createEntity('player');
e.add('inv', new Inventory());
expect(e.get(Inventory)!.add({ itemId: 'ghost', amount: 1 })).toBeFalse();
});
});
describe('Inventory — finite mode (count)', () => {
it('accepts items up to slot capacity', () => {
const w = world();
Items.register(w, 'potion', 'Potion', { maxStack: 5 });
const e = w.createEntity('player');
e.add('inv', new Inventory(2)); // 2 slots × 5 stack = 10 max
expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 10 })).toBeTrue();
expect(e.get(Inventory)!.getAmount('potion')).toBe(10);
});
it('rejects add when capacity exceeded', () => {
const w = world();
Items.register(w, 'potion', 'Potion', { maxStack: 5 });
const e = w.createEntity('player');
e.add('inv', new Inventory(1)); // 1 slot × 5 = 5 max
expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 6 })).toBeFalse();
expect(e.get(Inventory)!.getAmount('potion')).toBe(0);
});
it('fills partial stacks before opening new slots', () => {
const w = world();
Items.register(w, 'stone', 'Stone', { maxStack: 10 });
const e = w.createEntity('player');
e.add('inv', new Inventory(2));
const inv = e.get(Inventory)!;
inv.add({ itemId: 'stone', amount: 8 });
inv.add({ itemId: 'stone', amount: 5 }); // fills slot 0 to 10, slot 1 to 3
expect(inv.getAmount('stone')).toBe(13);
});
});
describe('Inventory — named slots', () => {
it('add to specific named slotId', () => {
const w = world();
Items.register(w, 'herb', 'Herb', { maxStack: 10 });
const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'pocket', limit: 10 }]));
const inv = e.get(Inventory)!;
expect(inv.add({ itemId: 'herb', amount: 3, slotId: 'pocket' })).toBeTrue();
expect(inv.getSlotContents('pocket')).toEqual({ itemId: 'herb', amount: 3 });
});
it('rejects add to unknown slotId', () => {
const w = world();
Items.register(w, 'herb', 'Herb');
const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'pocket', limit: 10 }]));
expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 1, slotId: 'wallet' })).toBeFalse();
});
it('rejects add exceeding slot limit', () => {
const w = world();
Items.register(w, 'herb', 'Herb', { maxStack: 99 });
const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'slot', limit: 5 }]));
expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 6, slotId: 'slot' })).toBeFalse();
});
});
describe('Inventory — remove', () => {
it('removes partial amount', () => {
const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 99 });
const e = w.createEntity('player');
e.add('inv', new Inventory());
const inv = e.get(Inventory)!;
inv.add({ itemId: 'coin', amount: 50 });
inv.remove({ itemId: 'coin', amount: 20 });
expect(inv.getAmount('coin')).toBe(30);
});
it('returns false when removing more than available', () => {
const w = world();
Items.register(w, 'coin', 'Coin');
const e = w.createEntity('player');
e.add('inv', new Inventory());
const inv = e.get(Inventory)!;
inv.add({ itemId: 'coin', amount: 1 });
expect(inv.remove({ itemId: 'coin', amount: 10 })).toBeFalse();
expect(inv.getAmount('coin')).toBe(1);
});
it('removes from a specific slot', () => {
const w = world();
Items.register(w, 'gem', 'Gem');
const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 'a' }, { slotId: 'b' }]));
const inv = e.get(Inventory)!;
inv.add({ itemId: 'gem', amount: 1, slotId: 'a' });
inv.add({ itemId: 'gem', amount: 1, slotId: 'b' });
inv.remove({ itemId: 'gem', amount: 1, slotId: 'a' });
expect(inv.getSlotContents('a')).toBeNull();
expect(inv.getSlotContents('b')).toEqual({ itemId: 'gem', amount: 1 });
});
it('slot contents becomes null after full removal', () => {
const w = world();
Items.register(w, 'herb', 'Herb');
const e = w.createEntity('player');
e.add('inv', new Inventory([{ slotId: 's' }]));
const inv = e.get(Inventory)!;
inv.add({ itemId: 'herb', amount: 1, slotId: 's' });
inv.remove({ itemId: 'herb', amount: 1, slotId: 's' });
expect(inv.getSlotContents('s')).toBeNull();
});
});
describe('Inventory — getItems / getAmount', () => {
it('getItems aggregates across all slots', () => {
const w = world();
Items.register(w, 'coin', 'Coin', { maxStack: 5 });
const e = w.createEntity('player');
e.add('inv', new Inventory(3));
const inv = e.get(Inventory)!;
inv.add({ itemId: 'coin', amount: 12 });
expect(inv.getItems().get('coin')).toBe(12);
});
it('getAmount returns 0 for absent item', () => {
const w = world();
const e = w.createEntity('player');
e.add('inv', new Inventory());
expect(e.get(Inventory)!.getAmount('missing')).toBe(0);
});
});
describe('Inventory — equip', () => {
it('delegates to Equipment.equip', () => {
const w = world();
const sword = Items.register(w, 'sword', 'Sword');
sword.add('equippable', new Equippable('weapon'));
const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 }));
player.add('equipment', new Equipment([{ slotName: 'weapon', type: 'weapon' }]));
player.add('inv', new Inventory());
const inv = player.get(Inventory)!;
inv.add({ itemId: 'sword', amount: 1 });
expect(inv.equip({ itemId: 'sword' })).toBeTrue();
expect(player.get(Equipment)!.getItem('weapon')).toBe('sword');
});
});
describe('Inventory — use', () => {
it('executes Usable actions', async () => {
const w = world();
const player = w.createEntity('player');
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 },
});
player.get(Inventory)!.add({ itemId: 'potion', amount: 1 });
await player.get(Inventory)!.use({ itemId: 'potion' });
expect(player.get(Stat, 'str')!.value).toBe(15);
});
it('consumes item when consumeOnUse is true', async () => {
const w = world();
const player = w.createEntity('player');
player.add('inv', new Inventory());
Items.register(w, 'herb', 'Herb', {
maxStack: 99,
usable: { actions: [], consumeOnUse: true },
});
player.get(Inventory)!.add({ itemId: 'herb', amount: 3 });
await player.get(Inventory)!.use({ itemId: 'herb' });
expect(player.get(Inventory)!.getAmount('herb')).toBe(2);
});
});

View File

@ -0,0 +1,528 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { QuestLog, Quests } from '@common/rpg/components/questLog';
import { QuestSystem } from '@common/rpg/systems/quest';
import { Variables } from '@common/rpg/components/variables';
import type { Quest } from '@common/rpg/types';
// ── helpers ───────────────────────────────────────────────────────────────────
function world() {
const w = new World();
w.addSystem(new QuestSystem());
return w;
}
/** Minimal one-stage quest whose single objective checks `vars.done == true`. */
function simpleQuest(id = 'q1', actions: Quest['stages'][0]['actions'] = []): Quest {
return {
id,
title: 'Test Quest',
description: '',
stages: [{
id: 'stage0',
description: 'Do the thing',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }],
actions,
}],
};
}
/** Two-stage quest: stage 0 checks `vars.step >= 1`, stage 1 checks `vars.step >= 2`. */
function twoStageQuest(id = 'q2'): Quest {
return {
id,
title: 'Two-Stage Quest',
description: '',
stages: [
{
id: 'stage0',
description: 'Step 1',
objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'vars.step >= 1' }],
actions: [],
},
{
id: 'stage1',
description: 'Step 2',
objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'vars.step >= 2' }],
actions: [],
},
],
};
}
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));
return { player, vars, questLog };
}
// ── QuestLog — registration ───────────────────────────────────────────────────
describe('QuestLog — registration', () => {
it('constructor registers quests as inactive', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.getState('q1')?.status).toBe('inactive');
});
it('addQuest registers a quest after construction', () => {
const w = world();
const { questLog } = makePlayer(w);
questLog.addQuest(simpleQuest('late'));
expect(questLog.getState('late')?.status).toBe('inactive');
});
it('addQuest ignores duplicates', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.addQuest(simpleQuest()); // duplicate
expect(questLog.getQuest('q1')).toBeDefined();
});
it('getQuest returns the quest definition', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.getQuest('q1')?.title).toBe('Test Quest');
});
it('getQuest returns undefined for unknown id', () => {
const w = world();
const { questLog } = makePlayer(w);
expect(questLog.getQuest('ghost')).toBeUndefined();
});
});
// ── QuestLog — state transitions ──────────────────────────────────────────────
describe('QuestLog — transitions', () => {
it('start sets status to active', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.start('q1')).toBeTrue();
expect(questLog.getState('q1')?.status).toBe('active');
});
it('complete sets status to completed', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
expect(questLog.complete('q1')).toBeTrue();
expect(questLog.getState('q1')?.status).toBe('completed');
});
it('fail sets status to failed', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
expect(questLog.fail('q1')).toBeTrue();
expect(questLog.getState('q1')?.status).toBe('failed');
});
it('abandon returns quest to inactive', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
expect(questLog.abandon('q1')).toBeTrue();
expect(questLog.getState('q1')?.status).toBe('inactive');
});
it('abandon resets stageIndex to 0', () => {
const w = world();
const { questLog } = makePlayer(w, [twoStageQuest()]);
questLog.start('q2');
questLog._advance('q2'); // move to stage 1
questLog.abandon('q2');
expect(questLog.getState('q2')?.stageIndex).toBe(0);
});
it('start returns false for unknown quest', () => {
const w = world();
const { questLog } = makePlayer(w);
expect(questLog.start('ghost')).toBeFalse();
});
it('complete returns false when not active', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.complete('q1')).toBeFalse(); // inactive
});
it('fail returns false when not active', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.fail('q1')).toBeFalse();
});
it('cannot start an already active quest', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
expect(questLog.start('q1')).toBeFalse();
});
});
// ── QuestLog — events ─────────────────────────────────────────────────────────
describe('QuestLog — events', () => {
it("emits 'started' on start", () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.started', ({ data }) => events.push(data));
questLog.start('q1');
expect(events).toEqual([{ questId: 'q1' }]);
});
it("emits 'completed' on complete", () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.completed', ({ data }) => events.push(data));
questLog.start('q1');
questLog.complete('q1');
expect(events).toEqual([{ questId: 'q1' }]);
});
it("emits 'failed' on fail", () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.failed', ({ data }) => events.push(data));
questLog.start('q1');
questLog.fail('q1');
expect(events).toEqual([{ questId: 'q1' }]);
});
it("emits 'abandoned' on abandon", () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.abandoned', ({ data }) => events.push(data));
questLog.start('q1');
questLog.abandon('q1');
expect(events).toEqual([{ questId: 'q1' }]);
});
});
// ── QuestLog — stage and objective access ─────────────────────────────────────
describe('QuestLog — stage access', () => {
it('getStage returns current stage when active', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
expect(questLog.getStage('q1')?.id).toBe('stage0');
});
it('getStage returns undefined when inactive', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.getStage('q1')).toBeUndefined();
});
it('getObjectiveProgress returns objective status', () => {
const w = world();
const { player, vars, questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
const before = questLog.getObjectiveProgress('q1', player.context);
expect(before).toHaveLength(1);
expect(before![0].done).toBeFalse();
vars.set({ key: 'done', value: true });
const after = questLog.getObjectiveProgress('q1', player.context);
expect(after![0].done).toBeTrue();
});
it('getObjectiveProgress returns undefined when not active', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.getObjectiveProgress('q1', player.context)).toBeUndefined();
});
});
// ── QuestLog — availability ───────────────────────────────────────────────────
describe('QuestLog — availability', () => {
it('quest with no conditions is always available', () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.isAvailable('q1', player.context)).toBeTrue();
});
it('quest with unsatisfied condition is not available', () => {
const w = world();
const quest: Quest = { ...simpleQuest(), conditions: ['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 { player, vars, questLog } = makePlayer(w, [quest]);
vars.set({ key: 'unlocked', value: true });
expect(questLog.isAvailable('q1', player.context)).toBeTrue();
});
});
// ── QuestLog — variables / actions ────────────────────────────────────────────
describe('QuestLog — getVariables', () => {
it('exposes quest status and stage index', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
const vars = questLog.getVariables();
expect(vars['q1.status']).toBe('active');
expect(vars['q1.stage']).toBe(0);
});
});
describe('QuestLog — getActions', () => {
it('exposes start action for inactive quest', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
expect('q1.start' in questLog.getActions()).toBeTrue();
});
it('exposes complete/fail/abandon actions for active quest', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
const actions = questLog.getActions();
expect('q1.complete' in actions).toBeTrue();
expect('q1.fail' in actions).toBeTrue();
expect('q1.abandon' in actions).toBeTrue();
});
});
// ── QuestLog — _advance ───────────────────────────────────────────────────────
describe('QuestLog — _advance', () => {
it('moves to next stage', () => {
const w = world();
const { questLog } = makePlayer(w, [twoStageQuest()]);
questLog.start('q2');
questLog._advance('q2');
expect(questLog.getState('q2')?.stageIndex).toBe(1);
expect(questLog.getStage('q2')?.id).toBe('stage1');
});
it("emits 'stage' event when advancing", () => {
const w = world();
const { player, questLog } = makePlayer(w, [twoStageQuest()]);
const events: unknown[] = [];
player.on('questLog.stage', ({ data }) => events.push(data));
questLog.start('q2');
questLog._advance('q2');
expect((events[0] as any).questId).toBe('q2');
expect((events[0] as any).index).toBe(1);
});
it('completes quest when advancing past last stage', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
questLog._advance('q1');
expect(questLog.getState('q1')?.status).toBe('completed');
});
it("emits 'completed' when advancing past last stage", () => {
const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]);
const events: unknown[] = [];
player.on('questLog.completed', ({ data }) => events.push(data));
questLog.start('q1');
questLog._advance('q1');
expect(events).toEqual([{ questId: 'q1' }]);
});
});
// ── Quests.validate ───────────────────────────────────────────────────────────
describe('Quests.validate', () => {
it('returns no errors for a valid quest with known actions', () => {
const errors = Quests.validate(simpleQuest(), []);
expect(errors).toHaveLength(0);
});
it('returns error for unknown action type in a stage', () => {
const quest = simpleQuest('q', [{ type: 'unknown.action' }]);
const errors = Quests.validate(quest, []);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('unknown.action');
});
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']);
expect(errors).toHaveLength(0);
});
it('returns error for non-quest object', () => {
const errors = Quests.validate({ broken: true }, []);
expect(errors).toHaveLength(1);
});
});
// ── QuestSystem — objective completion ────────────────────────────────────────
describe('QuestSystem — objective completion', () => {
it('completes single-stage quest when objective is satisfied', async () => {
const w = world();
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
vars.set({ key: 'done', value: true });
await w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed');
});
it('does not complete quest while objective is unsatisfied', async () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
await w.update(1);
expect(questLog.getState('q1')?.status).toBe('active');
});
it('does not process inactive quests', async () => {
const w = world();
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
vars.set({ key: 'done', value: true });
await w.update(1);
expect(questLog.getState('q1')?.status).toBe('inactive');
});
it('runs stage actions before advancing', async () => {
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 { vars, questLog } = makePlayer(w, [quest]);
questLog.start('q1');
vars.set({ key: 'done', value: true });
await w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed');
expect(vars.state.vars['reward']).toBe(true);
});
});
describe('QuestSystem — multi-stage progression', () => {
it('advances through stages as objectives are satisfied', async () => {
const w = world();
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
questLog.start('q2');
// satisfy stage 0
vars.set({ key: 'step', value: 1 });
await w.update(1);
expect(questLog.getState('q2')?.stageIndex).toBe(1);
expect(questLog.getState('q2')?.status).toBe('active');
// satisfy stage 1
vars.set({ key: 'step', value: 2 });
await w.update(1);
expect(questLog.getState('q2')?.status).toBe('completed');
});
it('does not skip stages', async () => {
const w = world();
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
questLog.start('q2');
vars.set({ key: 'step', value: 2 }); // would satisfy both stages
await w.update(1);
// only advances one stage per tick
expect(questLog.getState('q2')?.stageIndex).toBe(1);
await w.update(1); // second tick completes it
expect(questLog.getState('q2')?.status).toBe('completed');
});
});
describe('QuestSystem — fail conditions', () => {
it('fails quest when failCondition is met', async () => {
const w = world();
const quest: Quest = {
id: 'q1',
title: 'Timed Quest',
description: '',
stages: [{
id: 'stage0',
description: 'Do it',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }],
actions: [],
failConditions: ['vars.failed == true'],
}],
};
const { vars, questLog } = makePlayer(w, [quest]);
questLog.start('q1');
vars.set({ key: 'failed', value: true });
await w.update(1);
expect(questLog.getState('q1')?.status).toBe('failed');
});
it('fail condition takes priority over objective completion', async () => {
const w = world();
const quest: Quest = {
id: 'q1',
title: 'Conflict Quest',
description: '',
stages: [{
id: 'stage0',
description: 'Both',
objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }],
actions: [],
failConditions: ['vars.done == true'], // same condition
}],
};
const { vars, questLog } = makePlayer(w, [quest]);
questLog.start('q1');
vars.set({ key: 'done', value: true });
await w.update(1);
expect(questLog.getState('q1')?.status).toBe('failed'); // fail checked first
});
});
describe('QuestSystem — multiple quests', () => {
it('tracks multiple quests independently', async () => {
const w = world();
const player = w.createEntity('player');
const vars = player.add('vars', new Variables());
const q1: Quest = {
id: 'q1', title: 'Q1', description: '',
stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: '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: [] }],
};
const log = player.add('questLog', new QuestLog([q1, q2]));
log.start('q1');
log.start('q2');
vars.set({ key: 'done1', value: true }); // only q1's condition satisfied
await w.update(1);
expect(log.getState('q1')?.status).toBe('completed');
expect(log.getState('q2')?.status).toBe('active');
});
});

View File

@ -0,0 +1,181 @@
import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Stat, Health } from '@common/rpg/components/stat';
function world() { return new World(); }
describe('Stat — value / base / modifiers', () => {
it('value equals base when no modifiers', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
expect(s.value).toBe(10);
expect(s.base).toBe(10);
});
it('set() changes base and value', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 5 }));
const s = e.get(Stat, 's')!;
s.set(20);
expect(s.base).toBe(20);
expect(s.value).toBe(20);
});
it('update() adds to base', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
s.update(5);
expect(s.value).toBe(15);
});
it('applyModifier shifts value without changing base', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
s.applyModifier(5);
expect(s.base).toBe(10);
expect(s.value).toBe(15);
});
it('removeModifier reverts applyModifier', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
s.applyModifier(5);
s.removeModifier(5);
expect(s.value).toBe(10);
});
it('multiple modifiers stack', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
s.applyModifier(3);
s.applyModifier(7);
expect(s.value).toBe(20);
});
it('modifier on max field shifts effective max', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10, max: 20 }));
const s = e.get(Stat, 's')!;
s.applyModifier(10, 'max');
expect(s.max).toBe(30);
});
it('modifier on min field shifts effective min', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0 }));
const s = e.get(Stat, 's')!;
s.applyModifier(5, 'min');
expect(s.min).toBe(5);
});
it('value is clamped to [min, max]', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0, max: 20 }));
const s = e.get(Stat, 's')!;
s.set(100);
expect(s.value).toBe(20);
s.set(-5);
expect(s.value).toBe(0);
});
it('large positibe modifier clamps to max', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10, max: 20 }));
const s = e.get(Stat, 's')!;
s.applyModifier(100);
expect(s.value).toBe(20);
});
it('large negative modifier clamps to min', () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10, min: 0 }));
const s = e.get(Stat, 's')!;
s.applyModifier(-100);
expect(s.value).toBe(0);
});
it("set() emits 'set' event with prev and value", () => {
const w = world();
const e = w.createEntity();
e.add('s', new Stat({ value: 10 }));
const s = e.get(Stat, 's')!;
const events: unknown[] = [];
e.on('s.set', ({ data }) => events.push(data));
s.set(20);
expect(events).toEqual([{ prev: 10, value: 20 }]);
});
it("set() does not emit 'set' when value unchanged", () => {
const w = world();
const e = w.createEntity();
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));
s.set(-100); // clamped to 0, still changes
s.set(-200); // still 0, no change
expect(events.length).toBe(1);
});
});
describe('Health', () => {
it('update reduces health', () => {
const w = world();
const e = w.createEntity();
e.add('health', new Health({ value: 100, min: 0 }));
const h = e.get(Health)!;
h.update(-30);
expect(h.value).toBe(70);
});
it('update to zero triggers kill()', () => {
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.killed', () => killed.push(true));
h.update(-10);
expect(killed.length).toBe(1);
expect(h.value).toBe(0);
});
it('kill() emits killed and sets value to 0', () => {
const w = world();
const e = w.createEntity();
e.add('health', new Health({ value: 50, min: 0 }));
const h = e.get(Health)!;
const killed: unknown[] = [];
e.on('health.killed', () => killed.push(true));
h.kill();
expect(killed.length).toBe(1);
expect(h.value).toBe(0);
});
it('kill() does not emit killed twice for overkill', () => {
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.killed', () => killed.push(true));
h.update(-999);
expect(killed.length).toBe(1);
});
});

View File

@ -0,0 +1,287 @@
import { describe, it, expect, mock } from 'bun:test';
import { World, Entity, Component } from '@common/rpg/core/world';
class Counter extends Component<{ n: number }> {
constructor(n = 0) { super({ n }); }
get n() { return this.state.n; }
inc() { this.state.n++; }
}
class Tag extends Component<{}> {
constructor() { super({}); }
}
describe('World — entity management', () => {
it('creates entity with auto id', () => {
const world = new World();
const e = world.createEntity();
expect(e.id).toMatch(/^entity_\d+$/);
});
it('creates entity with explicit id', () => {
const world = new World();
const e = world.createEntity('player');
expect(e.id).toBe('player');
});
it('creates entity with template id', () => {
const world = new World();
const a = world.createEntity('enemy_*');
const b = world.createEntity('enemy_*');
expect(a.id).not.toBe(b.id);
expect(a.id).toMatch(/^enemy_\d+$/);
});
it('throws on duplicate entity id', () => {
const world = new World();
world.createEntity('dup');
expect(() => world.createEntity('dup')).toThrow();
});
it('getEntity returns entity or undefined', () => {
const world = new World();
world.createEntity('e');
expect(world.getEntity('e')).toBeInstanceOf(Entity);
expect(world.getEntity('missing')).toBeUndefined();
});
it('destroyEntity removes entity and its components', () => {
const world = new World();
const e = world.createEntity('e');
const removed: string[] = [];
class Tracked extends Component<{}> {
constructor() { super({}); }
override onRemove() { removed.push('ok'); }
}
e.add('t', new Tracked());
world.destroyEntity(e);
expect(world.getEntity('e')).toBeUndefined();
expect(removed).toEqual(['ok']);
});
it('iterates all entities', () => {
const world = new World();
world.createEntity('a');
world.createEntity('b');
const ids = [...world].map(e => e.id);
expect(ids).toContain('a');
expect(ids).toContain('b');
});
});
describe('Entity — components', () => {
it('add / get by key', () => {
const world = new World();
const e = world.createEntity();
e.add('counter', new Counter(5));
expect(e.get<Counter>('counter')?.n).toBe(5);
});
it('get by class', () => {
const world = new World();
const e = world.createEntity();
e.add('c', new Counter(3));
expect(e.get(Counter)?.n).toBe(3);
});
it('get by class and key', () => {
const world = new World();
const e = world.createEntity();
e.add('a', new Counter(1));
e.add('b', new Counter(2));
expect(e.get(Counter, 'b')?.n).toBe(2);
});
it('get by class and filter', () => {
const world = new World();
const e = world.createEntity();
e.add('a', new Counter(10));
e.add('b', new Counter(20));
expect(e.get(Counter, c => c.n === 20)?.n).toBe(20);
});
it('getAll returns all matching components', () => {
const world = new World();
const e = world.createEntity();
e.add('a', new Counter(1));
e.add('b', new Counter(2));
e.add('t', new Tag());
const all = e.getAll(Counter);
expect(all.length).toBe(2);
expect(all.map(c => c.n).sort()).toEqual([1, 2]);
});
it('has by key / by class / by class+key', () => {
const world = new World();
const e = world.createEntity();
e.add('c', new Counter());
expect(e.has('c')).toBeTrue();
expect(e.has('missing')).toBeFalse();
expect(e.has(Counter)).toBeTrue();
expect(e.has(Tag)).toBeFalse();
expect(e.has(Counter, 'c')).toBeTrue();
expect(e.has(Counter, 'missing')).toBeFalse();
});
it('remove by key calls onRemove', () => {
const world = new World();
const e = world.createEntity();
const removed: boolean[] = [];
class R extends Component<{}> {
constructor() { super({}); }
override onRemove() { removed.push(true); }
}
e.add('r', new R());
e.remove('r');
expect(removed).toEqual([true]);
expect(e.has('r')).toBeFalse();
});
it('remove by component instance', () => {
const world = new World();
const e = world.createEntity();
const c = new Counter();
e.add('c', c);
e.remove(c);
expect(e.has('c')).toBeFalse();
});
it('add over existing key calls onRemove on old', () => {
const world = new World();
const e = world.createEntity();
const events: string[] = [];
class Ev extends Component<{ id: string }> {
constructor(id: string) { super({ id }); }
override onAdd() { events.push(`add:${this.state.id}`); }
override onRemove() { events.push(`remove:${this.state.id}`); }
}
e.add('k', new Ev('a'));
e.add('k', new Ev('b'));
expect(events).toEqual(['add:a', 'remove:a', 'add:b']);
});
it('component.entity and component.key are set on add', () => {
const world = new World();
const e = world.createEntity('me');
const c = new Counter();
e.add('mykey', c);
expect(c.entity).toBe(e);
expect(c.key).toBe('mykey');
});
});
describe('Entity — clone', () => {
it('clones component state deeply', () => {
const world = new World();
const e = world.createEntity();
const orig = new Counter(7);
e.add('c', orig);
const clone = e.clone('d', orig);
clone.inc();
expect(orig.n).toBe(7);
expect(clone.n).toBe(8);
});
it('clone fires onAdd', () => {
const world = new World();
const e = world.createEntity();
const added: boolean[] = [];
class A extends Component<{}> {
constructor() { super({}); }
override onAdd() { added.push(true); }
}
const a = new A();
e.add('a', a);
added.length = 0;
e.clone('b', a);
expect(added).toEqual([true]);
});
});
describe('World — cloneEntity', () => {
it('produces independent deep copy', () => {
const world = new World();
const src = world.createEntity('src');
src.add('c', new Counter(5));
const copy = world.cloneEntity(src, 'copy');
copy.get(Counter)!.inc();
expect(src.get(Counter)!.n).toBe(5);
expect(copy.get(Counter)!.n).toBe(6);
});
});
describe('World — query', () => {
it('single-component query yields matching entities', () => {
const world = new World();
const a = world.createEntity('a');
const b = world.createEntity('b');
world.createEntity('c');
a.add('c', new Counter());
b.add('c', new Counter());
const found = [...world.query(Counter)].map(([e]) => e.id);
expect(found.sort()).toEqual(['a', 'b']);
});
it('multi-component query requires all', () => {
const world = new World();
const a = world.createEntity('a');
const b = world.createEntity('b');
a.add('c', new Counter());
a.add('t', new Tag());
b.add('c', new Counter());
const found = [...world.query(Counter, Tag)].map(([e]) => e.id);
expect(found).toEqual(['a']);
});
});
describe('Entity — events', () => {
it('emit / on fires handler', () => {
const world = new World();
const e = world.createEntity();
const received: unknown[] = [];
e.on('boom', ({ data }) => received.push(data));
e.emit('boom', 42);
expect(received).toEqual([42]);
});
it('off unsubscribes handler', () => {
const world = new World();
const e = world.createEntity();
const received: unknown[] = [];
const handler = ({ data }: { data?: unknown }) => received.push(data);
e.on('x', handler);
e.off('x', handler);
e.emit('x', 1);
expect(received).toEqual([]);
});
it('once fires exactly once', () => {
const world = new World();
const e = world.createEntity();
const received: unknown[] = [];
e.once('x', ({ data }) => received.push(data));
e.emit('x', 1);
e.emit('x', 2);
expect(received).toEqual([1]);
});
it('on returns unsubscribe function', () => {
const world = new World();
const e = world.createEntity();
const received: unknown[] = [];
const unsub = e.on('x', ({ data }) => received.push(data));
unsub();
e.emit('x', 1);
expect(received).toEqual([]);
});
it('destroying entity removes all event handlers', () => {
const world = new World();
const e = world.createEntity('e');
const received: unknown[] = [];
e.on('x', ({ data }) => received.push(data));
world.destroyEntity(e);
// no error, handler just never fires
expect(received).toEqual([]);
});
});