Tests
This commit is contained in:
parent
693b078144
commit
6d5cd0b2cc
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 }> { }
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EquipmentState> {
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<InventoryState> {
|
||||
#cachedVars: RPGVariables | null = null;
|
||||
|
|
@ -29,27 +51,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
/** 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 {
|
||||
|
|
|
|||
|
|
@ -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<StatState> {
|
||||
constructor(value: number, max?: number, min?: number) {
|
||||
super({ base: value, modifierSums: { value: 0, max: 0, min: 0 }, max, min });
|
||||
export class Stat<T = {}> extends Component<StatState & T> {
|
||||
constructor(args: Omit<StatState, 'modifierSums' | 'base'> & 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 { }
|
||||
|
||||
|
|
|
|||
|
|
@ -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<any>): 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 {
|
||||
|
|
|
|||
|
|
@ -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<TState = Record<string, unknown>> {
|
|||
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<TState = Record<string, unknown>> {
|
|||
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<void> { };
|
||||
}
|
||||
|
||||
type ComponentFilter<T> = (component: T) => boolean;
|
||||
|
||||
export class Entity {
|
||||
readonly #components = new Map<string, Component>();
|
||||
|
||||
|
|
@ -105,23 +105,41 @@ export class Entity {
|
|||
return component;
|
||||
}
|
||||
|
||||
clone<T extends Component<any>>(key: string, component: T): T {
|
||||
const clone = Object.create(component.constructor.prototype) as T;
|
||||
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
||||
return this.add(key, clone);
|
||||
}
|
||||
|
||||
get<T extends Component<any>>(key: string): T | undefined;
|
||||
get<T extends Component<any>>(ctor: Class<T>): T | undefined;
|
||||
get<T extends Component<any>>(ctor: Class<T>, key: string): T | undefined;
|
||||
get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
|
||||
get<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): T | undefined;
|
||||
get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): T | undefined {
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
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<T extends Component<any>>(ctor: Class<T>): T[] {
|
||||
const result: T[] = [];
|
||||
for (const c of this.#components.values()) {
|
||||
if (c instanceof ctor) result.push(c as T);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
has(key: string): boolean;
|
||||
has<T extends Component<any>>(ctor: Class<T>): boolean;
|
||||
has<T extends Component<any>>(ctor: Class<T>, key: string): boolean;
|
||||
|
|
@ -136,12 +154,17 @@ export class Entity {
|
|||
|
||||
remove(key: string): void;
|
||||
remove<T extends Component<any>>(ctor: Class<T>): void;
|
||||
remove<T extends Component<any>>(component: T): void;
|
||||
remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
|
||||
remove<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): void {
|
||||
remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string): void {
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
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<any>;
|
||||
(clone as unknown as { state: unknown }).state = structuredClone(component[COMPONENT_STATE]());
|
||||
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
||||
target.add(key, clone);
|
||||
}
|
||||
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<void> {
|
||||
for (const system of this.#systems) await system.update(this, dt);
|
||||
}
|
||||
|
||||
emit(entityId: string, event: string, data?: unknown): void {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { Attacked, Damage, Defense } from "../components/combat";
|
||||
import { Effect } from "../components/effect";
|
||||
import { Health } from "../components/stat";
|
||||
import { System, World } from "../core/world";
|
||||
|
||||
let hitEffectCounter = 0;
|
||||
|
||||
export class CombatSystem extends System {
|
||||
override async update(world: World): Promise<void> {
|
||||
for (const [target] of world.query(Attacked)) {
|
||||
const health = target.get(Health);
|
||||
if (!health) {
|
||||
console.warn(`[CombatSystem] Target ${target.id} has no Health component`);
|
||||
for (const attack of target.getAll(Attacked)) target.remove(attack);
|
||||
continue;
|
||||
}
|
||||
|
||||
let damageSum = 0;
|
||||
const hitEvents: { attackerId: string; sourceId: string | null; amount: number; damageType: string }[] = [];
|
||||
let lastHit: { attackerId: string; sourceId: string | null } | null = null;
|
||||
|
||||
for (const attack of target.getAll(Attacked)) {
|
||||
const { attackerId, sourceId } = attack.state;
|
||||
target.remove(attack);
|
||||
|
||||
const attacker = world.getEntity(attackerId);
|
||||
if (!attacker) {
|
||||
console.warn(`[CombatSystem] Attacker ${attackerId} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = sourceId ? world.getEntity(sourceId) : attacker;
|
||||
if (!source) {
|
||||
console.warn(`[CombatSystem] Source ${sourceId} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const damage = source.get(Damage);
|
||||
if (!damage) {
|
||||
console.warn(`[CombatSystem] No Damage on source ${source.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { damageType, minDamage = 0 } = damage.state;
|
||||
let damageAmount = damage.value;
|
||||
|
||||
const defense = target.get(Defense, (c) => c.state.damageType === damageType);
|
||||
if (defense) {
|
||||
damageAmount = Math.max(minDamage, damageAmount - defense.value);
|
||||
}
|
||||
|
||||
// Apply on-hit effects from source onto target
|
||||
for (const [key, component] of source) {
|
||||
if (!(component instanceof Effect) || component.state.scope !== 'onHit') continue;
|
||||
const s = component.state;
|
||||
target.add(
|
||||
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
|
||||
new Effect(s.targetStat, s.delta, s.targetField, s.duration ?? undefined, s.condition ?? undefined),
|
||||
);
|
||||
}
|
||||
|
||||
damageSum += damageAmount;
|
||||
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
|
||||
lastHit = { attackerId, sourceId };
|
||||
}
|
||||
|
||||
if (damageSum === 0) continue;
|
||||
|
||||
const wasAlive = health.value > 0;
|
||||
health.update(-damageSum);
|
||||
|
||||
for (const info of hitEvents) {
|
||||
target.emit('hit', info);
|
||||
}
|
||||
|
||||
if (wasAlive && health.value <= 0 && lastHit) {
|
||||
target.emit('kill', lastHit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
for (const [, , cooldown] of world.query(Cooldown)) {
|
||||
cooldown.update(dt);
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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 }) => {
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { World } from '@common/rpg/core/world';
|
||||
import { Stat } from '@common/rpg/components/stat';
|
||||
import { Effect } from '@common/rpg/components/effect';
|
||||
import { Equipment, Equippable } from '@common/rpg/components/equipment';
|
||||
|
||||
function world() { return new World(); }
|
||||
|
||||
function makeSword(w: World, id = 'sword') {
|
||||
const sword = w.createEntity(id);
|
||||
sword.add('equippable', new Equippable('weapon'));
|
||||
return sword;
|
||||
}
|
||||
|
||||
function makePlayer(w: World, slots: ConstructorParameters<typeof Equipment>[0] = [{ slotName: 'weapon', type: 'weapon' }]) {
|
||||
const player = w.createEntity('player');
|
||||
player.add('str', new Stat({ value: 10 }));
|
||||
player.add('equipment', new Equipment(slots));
|
||||
return player;
|
||||
}
|
||||
|
||||
describe('Equipment — equip', () => {
|
||||
it('equips item into a typed slot', () => {
|
||||
const w = world();
|
||||
makeSword(w);
|
||||
const player = makePlayer(w);
|
||||
const result = player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(result).toBeTrue();
|
||||
expect(player.get(Equipment)!.getItem('weapon')).toBe('sword');
|
||||
});
|
||||
|
||||
it('returns false for unknown slot', () => {
|
||||
const w = world();
|
||||
makeSword(w);
|
||||
const player = makePlayer(w);
|
||||
expect(player.get(Equipment)!.equip({ slotName: 'head', itemId: 'sword' })).toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for missing item entity', () => {
|
||||
const w = world();
|
||||
const player = makePlayer(w);
|
||||
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'ghost' })).toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when item has no Equippable component', () => {
|
||||
const w = world();
|
||||
w.createEntity('rock'); // no Equippable
|
||||
const player = makePlayer(w);
|
||||
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'rock' })).toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when slot type does not match Equippable.slotType', () => {
|
||||
const w = world();
|
||||
const helmet = w.createEntity('helmet');
|
||||
helmet.add('equippable', new Equippable('armor'));
|
||||
const player = makePlayer(w); // slot type = 'weapon'
|
||||
expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'helmet' })).toBeFalse();
|
||||
});
|
||||
|
||||
it('generic slot (no type) accepts any Equippable', () => {
|
||||
const w = world();
|
||||
makeSword(w);
|
||||
const player = w.createEntity('player');
|
||||
player.add('equipment', new Equipment(['slot1'])); // generic slot
|
||||
expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue();
|
||||
});
|
||||
|
||||
it('equipping occupied slot auto-unequips first', () => {
|
||||
const w = world();
|
||||
makeSword(w, 'sword1');
|
||||
makeSword(w, 'sword2');
|
||||
const player = makePlayer(w);
|
||||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword1' });
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword2' });
|
||||
expect(eq.getItem('weapon')).toBe('sword2');
|
||||
});
|
||||
|
||||
it("emits 'equip' event", () => {
|
||||
const w = world();
|
||||
makeSword(w);
|
||||
const player = makePlayer(w);
|
||||
const events: unknown[] = [];
|
||||
player.on('equipment.equip', ({ data }) => events.push(data));
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Equipment — equip-scope effects', () => {
|
||||
it('clones equip-scope Effect onto owner on equip', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('bonus', new Effect('str', 5)); // scope: equip (default)
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(15);
|
||||
});
|
||||
|
||||
it('does NOT clone onHit-scope Effect onto owner on equip', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
|
||||
expect(player.getAll(Effect).length).toBe(0);
|
||||
});
|
||||
|
||||
it('clones multiple equip effects', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('a', new Effect('str', 3));
|
||||
sword.add('b', new Effect('str', 7));
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(20);
|
||||
});
|
||||
|
||||
it('equip + onHit: only equip effects reach owner', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('passive', new Effect('str', 5));
|
||||
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(15);
|
||||
expect(player.getAll(Effect).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Equipment — unequip', () => {
|
||||
it('unequip reverts cloned effects', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('bonus', new Effect('str', 5));
|
||||
const player = makePlayer(w);
|
||||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(15);
|
||||
eq.unequip('weapon');
|
||||
expect(player.get(Stat, 'str')!.value).toBe(10);
|
||||
});
|
||||
|
||||
it('unequip clears slot itemId', () => {
|
||||
const w = world();
|
||||
makeSword(w);
|
||||
const player = makePlayer(w);
|
||||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
eq.unequip('weapon');
|
||||
expect(eq.getItem('weapon')).toBeNull();
|
||||
});
|
||||
|
||||
it('unequip returns false on empty slot', () => {
|
||||
const w = world();
|
||||
const player = makePlayer(w);
|
||||
expect(player.get(Equipment)!.unequip('weapon')).toBeFalse();
|
||||
});
|
||||
|
||||
it("unequip emits 'unequip' event", () => {
|
||||
const w = world();
|
||||
makeSword(w);
|
||||
const player = makePlayer(w);
|
||||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
const events: unknown[] = [];
|
||||
player.on('equipment.unequip', ({ data }) => events.push(data));
|
||||
eq.unequip('weapon');
|
||||
expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Equipment — queries', () => {
|
||||
it('getEquipped returns all filled slots', () => {
|
||||
const w = world();
|
||||
makeSword(w, 'sword1');
|
||||
makeSword(w, 'sword2');
|
||||
const player = w.createEntity('player');
|
||||
player.add('equipment', new Equipment([
|
||||
{ slotName: 'main', type: 'weapon' },
|
||||
{ slotName: 'off', type: 'weapon' },
|
||||
]));
|
||||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'main', itemId: 'sword1' });
|
||||
eq.equip({ slotName: 'off', itemId: 'sword2' });
|
||||
const equipped = eq.getEquipped();
|
||||
expect(equipped.length).toBe(2);
|
||||
expect(equipped.map(e => e.slotName).sort()).toEqual(['main', 'off']);
|
||||
});
|
||||
|
||||
it('findCompatibleSlot prefers typed slot over generic', () => {
|
||||
const w = world();
|
||||
const player = w.createEntity('player');
|
||||
player.add('equipment', new Equipment([
|
||||
'generic',
|
||||
{ slotName: 'weapon', type: 'weapon' },
|
||||
]));
|
||||
const eq = player.get(Equipment)!;
|
||||
expect(eq.findCompatibleSlot('weapon')).toBe('weapon');
|
||||
});
|
||||
|
||||
it('findCompatibleSlot falls back to generic slot', () => {
|
||||
const w = world();
|
||||
const player = w.createEntity('player');
|
||||
player.add('equipment', new Equipment(['generic']));
|
||||
const eq = player.get(Equipment)!;
|
||||
expect(eq.findCompatibleSlot('weapon')).toBe('generic');
|
||||
});
|
||||
|
||||
it('findCompatibleSlot returns null when no compatible slot', () => {
|
||||
const w = world();
|
||||
const player = w.createEntity('player');
|
||||
player.add('equipment', new Equipment([{ slotName: 'armor', type: 'armor' }]));
|
||||
const eq = player.get(Equipment)!;
|
||||
expect(eq.findCompatibleSlot('weapon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { World } from '@common/rpg/core/world';
|
||||
import { Experience } from '@common/rpg/components/experience';
|
||||
|
||||
function withXp(spec: ConstructorParameters<typeof Experience>[0]) {
|
||||
const w = new World();
|
||||
const e = w.createEntity();
|
||||
e.add('xp', new Experience(spec));
|
||||
return { e, xp: e.get(Experience, 'xp')! };
|
||||
}
|
||||
|
||||
describe('Experience — initial state', () => {
|
||||
it('starts at level 1, xp 0', () => {
|
||||
const { xp } = withXp([100, 200, 400]);
|
||||
expect(xp.level).toBe(1);
|
||||
expect(xp.xp).toBe(0);
|
||||
expect(xp.xpInLevel).toBe(0);
|
||||
});
|
||||
|
||||
it('progress is 0 at start', () => {
|
||||
const { xp } = withXp([100]);
|
||||
expect(xp.progress).toBe(0);
|
||||
});
|
||||
|
||||
it('xpToNext equals first threshold', () => {
|
||||
const { xp } = withXp([100, 300]);
|
||||
expect(xp.xpToNext).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Experience — award XP (array spec)', () => {
|
||||
it('accumulates xp without leveling up', () => {
|
||||
const { xp } = withXp([100]);
|
||||
xp.award(50);
|
||||
expect(xp.xp).toBe(50);
|
||||
expect(xp.level).toBe(1);
|
||||
expect(xp.xpInLevel).toBe(50);
|
||||
});
|
||||
|
||||
it('levels up when xp meets threshold', () => {
|
||||
const { xp } = withXp([100]);
|
||||
xp.award(100);
|
||||
expect(xp.level).toBe(2);
|
||||
expect(xp.xpInLevel).toBe(0);
|
||||
});
|
||||
|
||||
it("emits 'levelup' with prev and new level", () => {
|
||||
const { e, xp } = withXp([100]);
|
||||
const events: unknown[] = [];
|
||||
e.on('xp.levelup', ({ data }) => events.push(data));
|
||||
xp.award(100);
|
||||
expect(events).toEqual([{ level: 2, prev: 1 }]);
|
||||
});
|
||||
|
||||
it('handles multiple level-ups in a single award', () => {
|
||||
const { xp } = withXp([100, 200, 400]);
|
||||
xp.award(350); // 100 (L1→2) + 200 (L2→3) + 50 leftover
|
||||
expect(xp.level).toBe(3);
|
||||
expect(xp.xpInLevel).toBe(50);
|
||||
});
|
||||
|
||||
it("emits 'levelup' for each level gained", () => {
|
||||
const { e, xp } = withXp([100, 200]);
|
||||
const events: unknown[] = [];
|
||||
e.on('xp.levelup', ({ data }) => events.push(data));
|
||||
xp.award(300); // gains 2 levels
|
||||
expect(events.length).toBe(2);
|
||||
expect((events[0] as any).level).toBe(2);
|
||||
expect((events[1] as any).level).toBe(3);
|
||||
});
|
||||
|
||||
it('stops leveling at max level (array spec)', () => {
|
||||
const { xp } = withXp([100]); // max level = 2
|
||||
xp.award(9999);
|
||||
expect(xp.level).toBe(2);
|
||||
});
|
||||
|
||||
it('xpToNext is null at max level', () => {
|
||||
const { xp } = withXp([100]);
|
||||
xp.award(9999);
|
||||
expect(xp.xpToNext).toBeNull();
|
||||
});
|
||||
|
||||
it('progress is 1 at max level', () => {
|
||||
const { xp } = withXp([100]);
|
||||
xp.award(9999);
|
||||
expect(xp.progress).toBe(1);
|
||||
});
|
||||
|
||||
it('xp.total never resets across levels', () => {
|
||||
const { xp } = withXp([100, 200]);
|
||||
xp.award(400);
|
||||
expect(xp.xp).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Experience — award XP (geometric spec)', () => {
|
||||
it('levels up using geometric thresholds', () => {
|
||||
// base=100, factor=2 → L1→L2: 100xp, L2→L3: 200xp
|
||||
const { xp } = withXp({ base: 100, factor: 2 });
|
||||
xp.award(100);
|
||||
expect(xp.level).toBe(2);
|
||||
xp.award(200);
|
||||
expect(xp.level).toBe(3);
|
||||
});
|
||||
|
||||
it('has no max level with geometric spec', () => {
|
||||
const { xp } = withXp({ base: 10, factor: 1 }); // 10xp per level, forever
|
||||
xp.award(10000);
|
||||
expect(xp.level).toBeGreaterThan(100);
|
||||
expect(xp.xpToNext).not.toBeNull();
|
||||
});
|
||||
|
||||
it('progress tracks correctly within a level', () => {
|
||||
const { xp } = withXp({ base: 100, factor: 2 }); // 100xp for L1→2
|
||||
xp.award(40);
|
||||
expect(xp.progress).toBeCloseTo(0.4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Experience — xpInLevel / xpToNext', () => {
|
||||
it('xpInLevel resets after level-up', () => {
|
||||
const { xp } = withXp([100, 200]);
|
||||
xp.award(150); // levels up at 100, 50 left
|
||||
expect(xp.xpInLevel).toBe(50);
|
||||
expect(xp.xpToNext).toBe(150); // 200 - 50
|
||||
});
|
||||
|
||||
it('xpToNext decreases as xp accumulates', () => {
|
||||
const { xp } = withXp([100]);
|
||||
expect(xp.xpToNext).toBe(100);
|
||||
xp.award(30);
|
||||
expect(xp.xpToNext).toBe(70);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, it, expect, mock } from 'bun:test';
|
||||
import { World, Entity, Component } from '@common/rpg/core/world';
|
||||
|
||||
class Counter extends Component<{ n: number }> {
|
||||
constructor(n = 0) { super({ n }); }
|
||||
get n() { return this.state.n; }
|
||||
inc() { this.state.n++; }
|
||||
}
|
||||
|
||||
class Tag extends Component<{}> {
|
||||
constructor() { super({}); }
|
||||
}
|
||||
|
||||
describe('World — entity management', () => {
|
||||
it('creates entity with auto id', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
expect(e.id).toMatch(/^entity_\d+$/);
|
||||
});
|
||||
|
||||
it('creates entity with explicit id', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity('player');
|
||||
expect(e.id).toBe('player');
|
||||
});
|
||||
|
||||
it('creates entity with template id', () => {
|
||||
const world = new World();
|
||||
const a = world.createEntity('enemy_*');
|
||||
const b = world.createEntity('enemy_*');
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.id).toMatch(/^enemy_\d+$/);
|
||||
});
|
||||
|
||||
it('throws on duplicate entity id', () => {
|
||||
const world = new World();
|
||||
world.createEntity('dup');
|
||||
expect(() => world.createEntity('dup')).toThrow();
|
||||
});
|
||||
|
||||
it('getEntity returns entity or undefined', () => {
|
||||
const world = new World();
|
||||
world.createEntity('e');
|
||||
expect(world.getEntity('e')).toBeInstanceOf(Entity);
|
||||
expect(world.getEntity('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('destroyEntity removes entity and its components', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity('e');
|
||||
const removed: string[] = [];
|
||||
class Tracked extends Component<{}> {
|
||||
constructor() { super({}); }
|
||||
override onRemove() { removed.push('ok'); }
|
||||
}
|
||||
e.add('t', new Tracked());
|
||||
world.destroyEntity(e);
|
||||
expect(world.getEntity('e')).toBeUndefined();
|
||||
expect(removed).toEqual(['ok']);
|
||||
});
|
||||
|
||||
it('iterates all entities', () => {
|
||||
const world = new World();
|
||||
world.createEntity('a');
|
||||
world.createEntity('b');
|
||||
const ids = [...world].map(e => e.id);
|
||||
expect(ids).toContain('a');
|
||||
expect(ids).toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entity — components', () => {
|
||||
it('add / get by key', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
e.add('counter', new Counter(5));
|
||||
expect(e.get<Counter>('counter')?.n).toBe(5);
|
||||
});
|
||||
|
||||
it('get by class', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
e.add('c', new Counter(3));
|
||||
expect(e.get(Counter)?.n).toBe(3);
|
||||
});
|
||||
|
||||
it('get by class and key', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
e.add('a', new Counter(1));
|
||||
e.add('b', new Counter(2));
|
||||
expect(e.get(Counter, 'b')?.n).toBe(2);
|
||||
});
|
||||
|
||||
it('get by class and filter', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
e.add('a', new Counter(10));
|
||||
e.add('b', new Counter(20));
|
||||
expect(e.get(Counter, c => c.n === 20)?.n).toBe(20);
|
||||
});
|
||||
|
||||
it('getAll returns all matching components', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
e.add('a', new Counter(1));
|
||||
e.add('b', new Counter(2));
|
||||
e.add('t', new Tag());
|
||||
const all = e.getAll(Counter);
|
||||
expect(all.length).toBe(2);
|
||||
expect(all.map(c => c.n).sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('has by key / by class / by class+key', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
e.add('c', new Counter());
|
||||
expect(e.has('c')).toBeTrue();
|
||||
expect(e.has('missing')).toBeFalse();
|
||||
expect(e.has(Counter)).toBeTrue();
|
||||
expect(e.has(Tag)).toBeFalse();
|
||||
expect(e.has(Counter, 'c')).toBeTrue();
|
||||
expect(e.has(Counter, 'missing')).toBeFalse();
|
||||
});
|
||||
|
||||
it('remove by key calls onRemove', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const removed: boolean[] = [];
|
||||
class R extends Component<{}> {
|
||||
constructor() { super({}); }
|
||||
override onRemove() { removed.push(true); }
|
||||
}
|
||||
e.add('r', new R());
|
||||
e.remove('r');
|
||||
expect(removed).toEqual([true]);
|
||||
expect(e.has('r')).toBeFalse();
|
||||
});
|
||||
|
||||
it('remove by component instance', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const c = new Counter();
|
||||
e.add('c', c);
|
||||
e.remove(c);
|
||||
expect(e.has('c')).toBeFalse();
|
||||
});
|
||||
|
||||
it('add over existing key calls onRemove on old', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const events: string[] = [];
|
||||
class Ev extends Component<{ id: string }> {
|
||||
constructor(id: string) { super({ id }); }
|
||||
override onAdd() { events.push(`add:${this.state.id}`); }
|
||||
override onRemove() { events.push(`remove:${this.state.id}`); }
|
||||
}
|
||||
e.add('k', new Ev('a'));
|
||||
e.add('k', new Ev('b'));
|
||||
expect(events).toEqual(['add:a', 'remove:a', 'add:b']);
|
||||
});
|
||||
|
||||
it('component.entity and component.key are set on add', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity('me');
|
||||
const c = new Counter();
|
||||
e.add('mykey', c);
|
||||
expect(c.entity).toBe(e);
|
||||
expect(c.key).toBe('mykey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entity — clone', () => {
|
||||
it('clones component state deeply', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const orig = new Counter(7);
|
||||
e.add('c', orig);
|
||||
const clone = e.clone('d', orig);
|
||||
clone.inc();
|
||||
expect(orig.n).toBe(7);
|
||||
expect(clone.n).toBe(8);
|
||||
});
|
||||
|
||||
it('clone fires onAdd', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const added: boolean[] = [];
|
||||
class A extends Component<{}> {
|
||||
constructor() { super({}); }
|
||||
override onAdd() { added.push(true); }
|
||||
}
|
||||
const a = new A();
|
||||
e.add('a', a);
|
||||
added.length = 0;
|
||||
e.clone('b', a);
|
||||
expect(added).toEqual([true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('World — cloneEntity', () => {
|
||||
it('produces independent deep copy', () => {
|
||||
const world = new World();
|
||||
const src = world.createEntity('src');
|
||||
src.add('c', new Counter(5));
|
||||
const copy = world.cloneEntity(src, 'copy');
|
||||
copy.get(Counter)!.inc();
|
||||
expect(src.get(Counter)!.n).toBe(5);
|
||||
expect(copy.get(Counter)!.n).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('World — query', () => {
|
||||
it('single-component query yields matching entities', () => {
|
||||
const world = new World();
|
||||
const a = world.createEntity('a');
|
||||
const b = world.createEntity('b');
|
||||
world.createEntity('c');
|
||||
a.add('c', new Counter());
|
||||
b.add('c', new Counter());
|
||||
const found = [...world.query(Counter)].map(([e]) => e.id);
|
||||
expect(found.sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('multi-component query requires all', () => {
|
||||
const world = new World();
|
||||
const a = world.createEntity('a');
|
||||
const b = world.createEntity('b');
|
||||
a.add('c', new Counter());
|
||||
a.add('t', new Tag());
|
||||
b.add('c', new Counter());
|
||||
const found = [...world.query(Counter, Tag)].map(([e]) => e.id);
|
||||
expect(found).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entity — events', () => {
|
||||
it('emit / on fires handler', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const received: unknown[] = [];
|
||||
e.on('boom', ({ data }) => received.push(data));
|
||||
e.emit('boom', 42);
|
||||
expect(received).toEqual([42]);
|
||||
});
|
||||
|
||||
it('off unsubscribes handler', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const received: unknown[] = [];
|
||||
const handler = ({ data }: { data?: unknown }) => received.push(data);
|
||||
e.on('x', handler);
|
||||
e.off('x', handler);
|
||||
e.emit('x', 1);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('once fires exactly once', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const received: unknown[] = [];
|
||||
e.once('x', ({ data }) => received.push(data));
|
||||
e.emit('x', 1);
|
||||
e.emit('x', 2);
|
||||
expect(received).toEqual([1]);
|
||||
});
|
||||
|
||||
it('on returns unsubscribe function', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const received: unknown[] = [];
|
||||
const unsub = e.on('x', ({ data }) => received.push(data));
|
||||
unsub();
|
||||
e.emit('x', 1);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroying entity removes all event handlers', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity('e');
|
||||
const received: unknown[] = [];
|
||||
e.on('x', ({ data }) => received.push(data));
|
||||
world.destroyEntity(e);
|
||||
// no error, handler just never fires
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue