From 6d5cd0b2cc65d79702511e87d4aae9c57cbda904 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 29 Apr 2026 16:01:52 +0000 Subject: [PATCH] Tests --- src/common/rpg/TODO.md | 21 +- src/common/rpg/components/combat.ts | 12 + src/common/rpg/components/effect.ts | 10 +- src/common/rpg/components/equipment.ts | 14 +- src/common/rpg/components/inventory.ts | 44 +- src/common/rpg/components/stat.ts | 28 +- src/common/rpg/core/serialization.ts | 4 +- src/common/rpg/core/world.ts | 51 +- src/common/rpg/systems/combat.ts | 81 +++ .../{cooldownSystem.ts => cooldown.ts} | 2 +- .../systems/{effectSystem.ts => effect.ts} | 4 +- .../rpg/systems/{questSystem.ts => quest.ts} | 12 +- src/games/playground/index.tsx | 8 +- test/common/rpg/combat.test.ts | 247 ++++++++ test/common/rpg/cooldown.test.ts | 169 ++++++ test/common/rpg/effect.test.ts | 160 ++++++ test/common/rpg/equipment.test.ts | 218 ++++++++ test/common/rpg/experience.test.ts | 135 +++++ test/common/rpg/inventory.test.ts | 263 +++++++++ test/common/rpg/quest.test.ts | 528 ++++++++++++++++++ test/common/rpg/stat.test.ts | 181 ++++++ test/common/rpg/world.test.ts | 287 ++++++++++ 22 files changed, 2396 insertions(+), 83 deletions(-) create mode 100644 src/common/rpg/components/combat.ts create mode 100644 src/common/rpg/systems/combat.ts rename src/common/rpg/systems/{cooldownSystem.ts => cooldown.ts} (78%) rename src/common/rpg/systems/{effectSystem.ts => effect.ts} (78%) rename src/common/rpg/systems/{questSystem.ts => quest.ts} (94%) create mode 100644 test/common/rpg/combat.test.ts create mode 100644 test/common/rpg/cooldown.test.ts create mode 100644 test/common/rpg/effect.test.ts create mode 100644 test/common/rpg/equipment.test.ts create mode 100644 test/common/rpg/experience.test.ts create mode 100644 test/common/rpg/inventory.test.ts create mode 100644 test/common/rpg/quest.test.ts create mode 100644 test/common/rpg/stat.test.ts create mode 100644 test/common/rpg/world.test.ts diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md index bfb10a5..0eef0a7 100644 --- a/src/common/rpg/TODO.md +++ b/src/common/rpg/TODO.md @@ -9,18 +9,15 @@ definition registry (neutral/friendly/hostile thresholds) pairs with this. --- -## Missing Systems +## Deferred Combat Features -### `CombatSystem` (`systems/combatSystem.ts`) -Resolves attack attempts between entities. Needs a lightweight `Attack` marker -component (attacker entity ID, target entity ID, damage, damage type) that is added -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. +### Effect stacking rules +Add `stack` (default) / `unique` / `replace` modes with a tag discriminator to +`Effect`. Prevents e.g. multiple poison stacks when the design calls for one. -Keeping the attack as a component (rather than a direct method call) lets other -systems react before resolution (parry window, shield-block effects, etc.). +### Damage modifiers +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). diff --git a/src/common/rpg/components/combat.ts b/src/common/rpg/components/combat.ts new file mode 100644 index 0000000..57888cb --- /dev/null +++ b/src/common/rpg/components/combat.ts @@ -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 }> { } \ No newline at end of file diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index 5134518..2b8f193 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -12,6 +12,7 @@ export class Effect extends Component<{ duration: number | null; // null = permanent until removed remaining: number | null; // countdown in seconds; null for condition-based/permanent 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. */ @variable('.') active: boolean = false; @@ -22,6 +23,7 @@ export class Effect extends Component<{ targetField?: 'value' | 'max' | 'min', duration?: number, condition?: string, + scope?: 'equip' | 'onHit', ) { super({ targetStat, @@ -30,10 +32,12 @@ export class Effect extends Component<{ duration: duration ?? null, remaining: duration ?? null, condition: condition ?? null, + scope: scope ?? 'equip', }); } override onAdd(): void { + if (this.state.scope === 'onHit') return; const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.applyModifier(this.state.delta, this.state.targetField); @@ -42,6 +46,7 @@ export class Effect extends Component<{ } override onRemove(): void { + if (this.state.scope === 'onHit') return; const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.removeModifier(this.state.delta, this.state.targetField); @@ -66,7 +71,8 @@ export class Effect extends Component<{ 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) { this.state.remaining -= dt; if (this.state.remaining <= 0) { @@ -74,7 +80,7 @@ export class Effect extends Component<{ this.clear(); } } else if (this.state.condition != null) { - if (!evaluateCondition(this.state.condition, ctx ?? this.context)) { + if (!evaluateCondition(this.state.condition, ctx)) { this.clear(); } } diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index fe69e59..1532d3b 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -1,8 +1,9 @@ import { component } from "../core/registry"; -import { Component, COMPONENT_STATE } from "../core/world"; +import { Component } from "../core/world"; import { action } from "../utils/decorators"; import type { RPGVariables } from "../types"; import { Effect } from "./effect"; +import { Damage } from "./combat"; // ── Equippable ──────────────────────────────────────────────────────────────── @@ -111,13 +112,12 @@ export class Equipment extends Component { slot.itemId = itemId; this.#cachedVars = null; + let id = 0; for (const [key, component] of itemEntity) { - if (!(component instanceof Effect)) continue; - const clone = Object.create(Effect.prototype) as Effect; - (clone as unknown as { state: unknown }).state = - structuredClone(component[COMPONENT_STATE]()); - const effectKey = `__equip_${slotName}_${key}`; - this.entity.add(effectKey, clone); + if (!(component instanceof Effect) || component.state.scope === 'onHit') continue; + + const effectKey = `__equip_${slotName}_${key}_${id++}`; + this.entity.clone(effectKey, component); slot.appliedEffectKeys.push(effectKey); } diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index e4645a8..65a389e 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -18,6 +18,28 @@ interface InventoryState { 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 export class Inventory extends Component { #cachedVars: RPGVariables | null = null; @@ -29,27 +51,7 @@ export class Inventory extends Component { /** Named slots with optional per-slot stack limits (original behaviour). */ constructor(slots: InventorySlotInput[]); constructor(input?: number | InventorySlotInput[]) { - if (input === undefined) { - 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 }; - }), - }); - } + super(buildInventoryState(input)); } #slot(slotId: SlotId): SlotRecord | undefined { diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index 2fe630f..ddc19fe 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -5,14 +5,18 @@ import { component } from "../core/registry"; interface StatState { base: number; modifierSums: { value: number; max: number; min: number }; - max: number | undefined; - min: number | undefined; + max?: number; + min?: number; } @component -export class Stat extends Component { - constructor(value: number, max?: number, min?: number) { - super({ base: value, modifierSums: { value: 0, max: 0, min: 0 }, max, min }); +export class Stat extends Component { + constructor(args: Omit & T & { value: number }) { + super({ + base: args.value, + modifierSums: { value: 0, max: 0, min: 0 }, + ...args, + }); } @variable('.') get value(): number { @@ -68,11 +72,13 @@ export class Health extends Stat { this.set(0); 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 { } - diff --git a/src/common/rpg/core/serialization.ts b/src/common/rpg/core/serialization.ts index 654c573..1ada8bd 100644 --- a/src/common/rpg/core/serialization.ts +++ b/src/common/rpg/core/serialization.ts @@ -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'; /** Increment this when the WorldData/EntityData structure itself changes incompatibly. */ @@ -37,7 +37,7 @@ function serializeComponent(component: Component): ComponentData { ); } 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 { diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 1052959..8d01784 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -19,9 +19,6 @@ export interface EvalContext { 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. */ export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter'); @@ -29,13 +26,14 @@ export abstract class Component> { entity!: Entity; key!: string; - protected state: TState; + private _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 { this.entity.emit(`${this.key}.${event}`, data); @@ -80,9 +78,11 @@ export abstract class Component> { export abstract class System { onAdd(_world: World): void { } onRemove(_world: World): void { } - update(_world: World, _dt: number): void { }; + async update(_world: World, _dt: number): Promise { }; } +type ComponentFilter = (component: T) => boolean; + export class Entity { readonly #components = new Map(); @@ -105,23 +105,41 @@ export class Entity { return component; } + clone>(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>(key: string): T | undefined; get>(ctor: Class): T | undefined; get>(ctor: Class, key: string): T | undefined; - get>(ctorOrKey: Class | string, key?: string): T | undefined { + get>(ctor: Class, filter: ComponentFilter): T | undefined; + get>(ctorOrKey: Class | string, key?: string | ComponentFilter): T | undefined { if (typeof ctorOrKey === 'string') { return this.#components.get(ctorOrKey) as T | undefined; } - if (key !== undefined) { + if (typeof key === 'string') { const c = this.#components.get(key); return c instanceof ctorOrKey ? c as T : undefined; } 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; } + getAll>(ctor: Class): 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>(ctor: Class): boolean; has>(ctor: Class, key: string): boolean; @@ -136,12 +154,17 @@ export class Entity { remove(key: string): void; remove>(ctor: Class): void; + remove>(component: T): void; remove>(ctor: Class, key: string): void; - remove>(ctorOrKey: Class | string, key?: string): void { + remove>(ctorOrKey: Class | T | string, key?: string): void { if (typeof ctorOrKey === 'string') { this.#removeByKey(ctorOrKey); return; } + if (ctorOrKey instanceof Component) { + this.#removeByKey(ctorOrKey.key); + return; + } if (key !== undefined) { if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key); return; @@ -254,7 +277,7 @@ export class World { const target = this.createEntity(newId); for (const [key, component] of source) { const clone = Object.create(component.constructor.prototype) as Component; - (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); } return target; @@ -293,8 +316,8 @@ export class World { } } - update(dt: number): void { - for (const system of this.#systems) system.update(this, dt); + async update(dt: number): Promise { + for (const system of this.#systems) await system.update(this, dt); } emit(entityId: string, event: string, data?: unknown): void { diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts new file mode 100644 index 0000000..2c2e3e2 --- /dev/null +++ b/src/common/rpg/systems/combat.ts @@ -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 { + 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); + } + } + } +} diff --git a/src/common/rpg/systems/cooldownSystem.ts b/src/common/rpg/systems/cooldown.ts similarity index 78% rename from src/common/rpg/systems/cooldownSystem.ts rename to src/common/rpg/systems/cooldown.ts index eaccccb..9ebece7 100644 --- a/src/common/rpg/systems/cooldownSystem.ts +++ b/src/common/rpg/systems/cooldown.ts @@ -2,7 +2,7 @@ import { Cooldown } from "../components/cooldown"; import { System, type World } from "../core/world"; export class CooldownSystem extends System { - override update(world: World, dt: number): void { + override async update(world: World, dt: number): Promise { for (const [, , cooldown] of world.query(Cooldown)) { cooldown.update(dt); } diff --git a/src/common/rpg/systems/effectSystem.ts b/src/common/rpg/systems/effect.ts similarity index 78% rename from src/common/rpg/systems/effectSystem.ts rename to src/common/rpg/systems/effect.ts index 67c6918..7063377 100644 --- a/src/common/rpg/systems/effectSystem.ts +++ b/src/common/rpg/systems/effect.ts @@ -2,12 +2,12 @@ import { Effect } from "../components/effect"; import { System, type Entity, type World } from "../core/world"; export class EffectSystem extends System { - override update(world: World, dt: number): void { + override async update(world: World, dt: number): Promise { const expired: [Entity, string][] = []; for (const [entity, key, effect] of world.query(Effect)) { effect.update(dt, entity.context); - if (!effect.active) { + if (!effect.active && effect.state.scope !== 'onHit') { expired.push([entity, key]); } } diff --git a/src/common/rpg/systems/questSystem.ts b/src/common/rpg/systems/quest.ts similarity index 94% rename from src/common/rpg/systems/questSystem.ts rename to src/common/rpg/systems/quest.ts index f3907a4..1f6f111 100644 --- a/src/common/rpg/systems/questSystem.ts +++ b/src/common/rpg/systems/quest.ts @@ -27,12 +27,12 @@ export class QuestSystem extends System { this.#tracking.clear(); } - override update(world: World, _dt: number): void { + override async update(world: World, _dt: number): Promise { for (const [entity, key, questLog] of world.query(QuestLog)) { if (!this.#tracking.has(entity.id)) { this.#initTracking(entity, key, questLog); } - void this.#diffAndCheck(entity, world); + await this.#diffAndCheck(entity, world); } // Prune tracking for entities that no longer exist @@ -68,7 +68,7 @@ export class QuestSystem extends System { } // 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 quest = questLog.getQuest(questId); const state = questLog.getState(questId); @@ -76,15 +76,15 @@ export class QuestSystem extends System { if (stage) { this.#addQuestVars(tracking, questId, stage); // 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 }; this.#removeQuestVars(tracking, questId); 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 }) => { diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index e392334..a370d04 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -3,7 +3,7 @@ import { Inventory } from "@common/rpg/components/inventory"; import { Health } 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/questSystem"; +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"; @@ -17,7 +17,7 @@ export default async function main() { const player = world.createEntity('player'); 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('quests', new QuestLog([{ id: 'test', @@ -28,13 +28,11 @@ export default async function main() { console.log(resolveVariables(world)); - const inventory = player.get(Inventory)!; - inventory.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); + player.get(Inventory)?.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); const vars = player.get(Variables)!; vars.set({ key: 'test', value: 'test' }); - await executeAction({ type: 'inventory.add', arg: { itemId: 'boots', amount: 2 } }, player); await executeAction('player.quests.test.start', world); console.log(resolveActions(world)); diff --git a/test/common/rpg/combat.test.ts b/test/common/rpg/combat.test.ts new file mode 100644 index 0000000..14f2df2 --- /dev/null +++ b/test/common/rpg/combat.test.ts @@ -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); + }); +}); diff --git a/test/common/rpg/cooldown.test.ts b/test/common/rpg/cooldown.test.ts new file mode 100644 index 0000000..b541d1a --- /dev/null +++ b/test/common/rpg/cooldown.test.ts @@ -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(); + }); +}); diff --git a/test/common/rpg/effect.test.ts b/test/common/rpg/effect.test.ts new file mode 100644 index 0000000..50702c1 --- /dev/null +++ b/test/common/rpg/effect.test.ts @@ -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); + }); +}); diff --git a/test/common/rpg/equipment.test.ts b/test/common/rpg/equipment.test.ts new file mode 100644 index 0000000..5367b54 --- /dev/null +++ b/test/common/rpg/equipment.test.ts @@ -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[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(); + }); +}); diff --git a/test/common/rpg/experience.test.ts b/test/common/rpg/experience.test.ts new file mode 100644 index 0000000..40e5d1f --- /dev/null +++ b/test/common/rpg/experience.test.ts @@ -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[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); + }); +}); diff --git a/test/common/rpg/inventory.test.ts b/test/common/rpg/inventory.test.ts new file mode 100644 index 0000000..d183db4 --- /dev/null +++ b/test/common/rpg/inventory.test.ts @@ -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); + }); +}); diff --git a/test/common/rpg/quest.test.ts b/test/common/rpg/quest.test.ts new file mode 100644 index 0000000..928ec96 --- /dev/null +++ b/test/common/rpg/quest.test.ts @@ -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'); + }); +}); diff --git a/test/common/rpg/stat.test.ts b/test/common/rpg/stat.test.ts new file mode 100644 index 0000000..d5ed17d --- /dev/null +++ b/test/common/rpg/stat.test.ts @@ -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); + }); +}); diff --git a/test/common/rpg/world.test.ts b/test/common/rpg/world.test.ts new file mode 100644 index 0000000..88b93da --- /dev/null +++ b/test/common/rpg/world.test.ts @@ -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')?.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([]); + }); +});