1
0
Fork 0

Remove async actions

This commit is contained in:
Pabloader 2026-04-29 17:54:59 +00:00
parent 6d5cd0b2cc
commit d7a3830740
13 changed files with 107 additions and 105 deletions

View File

@ -237,7 +237,7 @@ export class Inventory extends Component<InventoryState> {
* `slotId` specifies which inventory slot to use from (otherwise any slot is used). * `slotId` specifies which inventory slot to use from (otherwise any slot is used).
*/ */
@action @action
async use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): Promise<boolean> { use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): boolean {
const resolved = this.#resolveItem(arg); const resolved = this.#resolveItem(arg);
if (!resolved) return false; if (!resolved) return false;
const { itemId, slotId } = resolved; const { itemId, slotId } = resolved;
@ -261,7 +261,7 @@ export class Inventory extends Component<InventoryState> {
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId }); if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
await usable.use(ctx ?? this.context); usable.use(ctx ?? this.context);
return true; return true;
} }

View File

@ -46,11 +46,11 @@ export class Usable extends Component<UsableState> {
@variable get consumeOnUse(): boolean { return this.state.consumeOnUse; } @variable get consumeOnUse(): boolean { return this.state.consumeOnUse; }
@action @action
async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> { use(arg?: EvalContext, ctx?: EvalContext): void {
ctx = arg ?? ctx ?? this.context; ctx = arg ?? ctx ?? this.context;
if (!ctx) return; if (!ctx) return;
for (const action of this.state.actions) { for (const action of this.state.actions) {
await executeAction(action, ctx); executeAction(action, ctx);
} }
} }
} }

View File

@ -78,7 +78,7 @@ export abstract class Component<TState = Record<string, unknown>> {
export abstract class System { export abstract class System {
onAdd(_world: World): void { } onAdd(_world: World): void { }
onRemove(_world: World): void { } onRemove(_world: World): void { }
async update(_world: World, _dt: number): Promise<void> { }; update(_world: World, _dt: number): void { };
} }
type ComponentFilter<T> = (component: T) => boolean; type ComponentFilter<T> = (component: T) => boolean;
@ -316,8 +316,10 @@ export class World {
} }
} }
async update(dt: number): Promise<void> { update(dt: number) {
for (const system of this.#systems) await system.update(this, dt); for (const system of this.#systems) {
system.update(this, dt);
}
} }
emit(entityId: string, event: string, data?: unknown): void { emit(entityId: string, event: string, data?: unknown): void {

View File

@ -6,7 +6,7 @@ import { System, World } from "../core/world";
let hitEffectCounter = 0; let hitEffectCounter = 0;
export class CombatSystem extends System { export class CombatSystem extends System {
override async update(world: World): Promise<void> { override update(world: World) {
for (const [target] of world.query(Attacked)) { for (const [target] of world.query(Attacked)) {
const health = target.get(Health); const health = target.get(Health);
if (!health) { if (!health) {

View File

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

View File

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

View File

@ -27,12 +27,12 @@ export class QuestSystem extends System {
this.#tracking.clear(); this.#tracking.clear();
} }
override async update(world: World, _dt: number): Promise<void> { override update(world: World) {
for (const [entity, key, questLog] of world.query(QuestLog)) { for (const [entity, key, questLog] of world.query(QuestLog)) {
if (!this.#tracking.has(entity.id)) { if (!this.#tracking.has(entity.id)) {
this.#initTracking(entity, key, questLog); this.#initTracking(entity, key, questLog);
} }
await this.#diffAndCheck(entity, world); this.#diffAndCheck(entity, world);
} }
// Prune tracking for entities that no longer exist // Prune tracking for entities that no longer exist
@ -45,9 +45,9 @@ export class QuestSystem extends System {
} }
/** Force a full re-evaluation of all active quests. Use as an escape hatch. */ /** Force a full re-evaluation of all active quests. Use as an escape hatch. */
async triggerCheck(world: World): Promise<void> { triggerCheck(world: World) {
for (const [entity] of world.query(QuestLog)) { for (const [entity] of world.query(QuestLog)) {
await this.#checkEntity(entity, world, 'all'); this.#checkEntity(entity, world, 'all');
} }
} }
@ -68,7 +68,7 @@ export class QuestSystem extends System {
} }
// Keep tracking fresh as quest state changes // Keep tracking fresh as quest state changes
const onStarted = async ({ data }: { data?: unknown }) => { const onStarted = ({ data }: { data?: unknown }) => {
const { questId } = data as { questId: string }; const { questId } = data as { questId: string };
const quest = questLog.getQuest(questId); const quest = questLog.getQuest(questId);
const state = questLog.getState(questId); const state = questLog.getState(questId);
@ -76,15 +76,15 @@ export class QuestSystem extends System {
if (stage) { if (stage) {
this.#addQuestVars(tracking, questId, stage); this.#addQuestVars(tracking, questId, stage);
// Evaluate immediately — conditions may already be satisfied at start // Evaluate immediately — conditions may already be satisfied at start
await this.#checkEntity(entity, entity.world, new Set([questId])); this.#checkEntity(entity, entity.world, new Set([questId]));
} }
}; };
const onStage = async ({ data }: { data?: unknown }) => { const onStage = ({ data }: { data?: unknown }) => {
const { questId, stage } = data as { questId: string; stage: QuestStage }; const { questId, stage } = data as { questId: string; stage: QuestStage };
this.#removeQuestVars(tracking, questId); this.#removeQuestVars(tracking, questId);
this.#addQuestVars(tracking, questId, stage); this.#addQuestVars(tracking, questId, stage);
await this.#checkEntity(entity, entity.world, new Set([questId])); this.#checkEntity(entity, entity.world, new Set([questId]));
}; };
const onDone = ({ data }: { data?: unknown }) => { const onDone = ({ data }: { data?: unknown }) => {
@ -128,7 +128,7 @@ export class QuestSystem extends System {
} }
} }
async #diffAndCheck(entity: Entity, world: World): Promise<void> { #diffAndCheck(entity: Entity, world: World) {
const tracking = this.#tracking.get(entity.id); const tracking = this.#tracking.get(entity.id);
if (!tracking || tracking.varToQuests.size === 0) return; if (!tracking || tracking.varToQuests.size === 0) return;
@ -145,10 +145,10 @@ export class QuestSystem extends System {
} }
} }
if (dirty.size > 0) await this.#checkEntity(entity, world, dirty); if (dirty.size > 0) this.#checkEntity(entity, world, dirty);
} }
async #checkEntity(entity: Entity, world: World, filter: Set<string> | 'all'): Promise<void> { #checkEntity(entity: Entity, world: World, filter: Set<string> | 'all') {
const questLog = entity.get(QuestLog); const questLog = entity.get(QuestLog);
if (!questLog) return; if (!questLog) return;
@ -171,7 +171,7 @@ export class QuestSystem extends System {
if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue; if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue;
for (const action of stage.actions) await executeAction(action, ctx); for (const action of stage.actions) executeAction(action, ctx);
questLog._advance(questId); questLog._advance(questId);
} }
} }

View File

@ -69,7 +69,7 @@ interface Contextable {
readonly context: EvalContext; readonly context: EvalContext;
} }
export async function executeAction(action: RPGAction, ctx: EvalContext | Contextable): Promise<unknown> { export function executeAction(action: RPGAction, ctx: EvalContext | Contextable): unknown {
if (typeof action === 'string') { if (typeof action === 'string') {
action = { type: action }; action = { type: action };
} }

View File

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

View File

@ -136,33 +136,33 @@ describe('Cooldown — update(dt)', () => {
}); });
describe('CooldownSystem', () => { describe('CooldownSystem', () => {
it('drives all cooldowns each tick', async () => { it('drives all cooldowns each tick', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(2)); e.add('cd', new Cooldown(2));
await w.update(1); w.update(1);
expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1); expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1);
}); });
it('marks cooldown ready after enough ticks', async () => { it('marks cooldown ready after enough ticks', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
e.add('cd', new Cooldown(2)); e.add('cd', new Cooldown(2));
const events: unknown[] = []; const events: unknown[] = [];
e.on('cd.ready', () => events.push(true)); e.on('cd.ready', () => events.push(true));
await w.update(1); w.update(1);
await w.update(1); w.update(1);
expect(events.length).toBe(1); expect(events.length).toBe(1);
expect(e.get(Cooldown, 'cd')!.ready).toBeTrue(); expect(e.get(Cooldown, 'cd')!.ready).toBeTrue();
}); });
it('handles multiple cooldowns on different entities', async () => { it('handles multiple cooldowns on different entities', () => {
const w = world(); const w = world();
const a = w.createEntity('a'); const a = w.createEntity('a');
const b = w.createEntity('b'); const b = w.createEntity('b');
a.add('cd', new Cooldown(1)); a.add('cd', new Cooldown(1));
b.add('cd', new Cooldown(3)); b.add('cd', new Cooldown(3));
await w.update(2); w.update(2);
expect(a.get(Cooldown, 'cd')!.ready).toBeTrue(); expect(a.get(Cooldown, 'cd')!.ready).toBeTrue();
expect(b.get(Cooldown, 'cd')!.ready).toBeFalse(); expect(b.get(Cooldown, 'cd')!.ready).toBeFalse();
}); });

View File

@ -54,61 +54,61 @@ describe('Effect — onAdd / onRemove', () => {
}); });
describe('Effect — duration', () => { describe('Effect — duration', () => {
it('expires after duration ticks', async () => { it('expires after duration ticks', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2)); e.add('fx', new Effect('str', 5, 'value', 2));
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
await w.update(1); w.update(1);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
await w.update(1); w.update(1);
expect(e.has(Effect)).toBeFalse(); expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('emits expired before removal', async () => { it('emits expired before removal', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect('str', 1, 'value', 1)); e.add('fx', new Effect('str', 1, 'value', 1));
const events: string[] = []; const events: string[] = [];
e.on('fx.expired', () => events.push('expired')); e.on('fx.expired', () => events.push('expired'));
await w.update(1); w.update(1);
expect(events).toEqual(['expired']); expect(events).toEqual(['expired']);
}); });
it('reset() restarts timer', async () => { it('reset() restarts timer', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2)); e.add('fx', new Effect('str', 5, 'value', 2));
await w.update(1.5); w.update(1.5);
e.get(Effect, 'fx')!.reset(); e.get(Effect, 'fx')!.reset();
await w.update(1.5); // would have expired without reset w.update(1.5); // would have expired without reset
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
}); });
it('reset(duration) changes duration', async () => { it('reset(duration) changes duration', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1)); e.add('fx', new Effect('str', 5, 'value', 1));
e.get(Effect, 'fx')!.reset(10); e.get(Effect, 'fx')!.reset(10);
await w.update(5); w.update(5);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
}); });
it('clear() immediately expires effect', async () => { it('clear() immediately expires effect', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 100)); e.add('fx', new Effect('str', 5, 'value', 100));
e.get(Effect, 'fx')!.clear(); e.get(Effect, 'fx')!.clear();
await w.update(0.01); w.update(0.01);
expect(e.has(Effect)).toBeFalse(); expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
}); });
describe('Effect — permanent', () => { describe('Effect — permanent', () => {
it('permanent effect is never removed by EffectSystem', async () => { it('permanent effect is never removed by EffectSystem', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5)); e.add('fx', new Effect('str', 5));
await w.update(100); w.update(100);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
}); });
@ -128,10 +128,10 @@ describe('Effect — scope: onHit', () => {
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('EffectSystem does not tick or remove onHit effects', async () => { it('EffectSystem does not tick or remove onHit effects', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit')); e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit'));
await w.update(10); w.update(10);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
}); });

View File

@ -235,7 +235,7 @@ describe('Inventory — equip', () => {
}); });
describe('Inventory — use', () => { describe('Inventory — use', () => {
it('executes Usable actions', async () => { it('executes Usable actions', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 })); player.add('str', new Stat({ value: 10 }));
@ -244,11 +244,11 @@ describe('Inventory — use', () => {
usable: { actions: [{ type: 'str.update', arg: 5 }], consumeOnUse: false }, usable: { actions: [{ type: 'str.update', arg: 5 }], consumeOnUse: false },
}); });
player.get(Inventory)!.add({ itemId: 'potion', amount: 1 }); player.get(Inventory)!.add({ itemId: 'potion', amount: 1 });
await player.get(Inventory)!.use({ itemId: 'potion' }); player.get(Inventory)!.use({ itemId: 'potion' });
expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.get(Stat, 'str')!.value).toBe(15);
}); });
it('consumes item when consumeOnUse is true', async () => { it('consumes item when consumeOnUse is true', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
player.add('inv', new Inventory()); player.add('inv', new Inventory());
@ -257,7 +257,7 @@ describe('Inventory — use', () => {
usable: { actions: [], consumeOnUse: true }, usable: { actions: [], consumeOnUse: true },
}); });
player.get(Inventory)!.add({ itemId: 'herb', amount: 3 }); player.get(Inventory)!.add({ itemId: 'herb', amount: 3 });
await player.get(Inventory)!.use({ itemId: 'herb' }); player.get(Inventory)!.use({ itemId: 'herb' });
expect(player.get(Inventory)!.getAmount('herb')).toBe(2); expect(player.get(Inventory)!.getAmount('herb')).toBe(2);
}); });
}); });

View File

@ -371,38 +371,38 @@ describe('Quests.validate', () => {
// ── QuestSystem — objective completion ──────────────────────────────────────── // ── QuestSystem — objective completion ────────────────────────────────────────
describe('QuestSystem — objective completion', () => { describe('QuestSystem — objective completion', () => {
it('completes single-stage quest when objective is satisfied', async () => { it('completes single-stage quest when objective is satisfied', () => {
const w = world(); const w = world();
const { vars, questLog } = makePlayer(w, [simpleQuest()]); const { vars, questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1'); questLog.start('q1');
vars.set({ key: 'done', value: true }); vars.set({ key: 'done', value: true });
await w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed'); expect(questLog.getState('q1')?.status).toBe('completed');
}); });
it('does not complete quest while objective is unsatisfied', async () => { it('does not complete quest while objective is unsatisfied', () => {
const w = world(); const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]); const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1'); questLog.start('q1');
await w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('active'); expect(questLog.getState('q1')?.status).toBe('active');
}); });
it('does not process inactive quests', async () => { it('does not process inactive quests', () => {
const w = world(); const w = world();
const { vars, questLog } = makePlayer(w, [simpleQuest()]); const { vars, questLog } = makePlayer(w, [simpleQuest()]);
vars.set({ key: 'done', value: true }); vars.set({ key: 'done', value: true });
await w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('inactive'); expect(questLog.getState('q1')?.status).toBe('inactive');
}); });
it('runs stage actions before advancing', async () => { it('runs stage actions before advancing', () => {
const w = world(); const w = world();
// action sets vars.reward = true on the player entity // action sets vars.reward = true on the player entity
const quest = simpleQuest('q1', [{ type: 'vars.set', arg: { key: 'reward', value: true } }]); const quest = simpleQuest('q1', [{ type: 'vars.set', arg: { key: 'reward', value: true } }]);
@ -410,7 +410,7 @@ describe('QuestSystem — objective completion', () => {
questLog.start('q1'); questLog.start('q1');
vars.set({ key: 'done', value: true }); vars.set({ key: 'done', value: true });
await w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed'); expect(questLog.getState('q1')?.status).toBe('completed');
expect(vars.state.vars['reward']).toBe(true); expect(vars.state.vars['reward']).toBe(true);
@ -418,7 +418,7 @@ describe('QuestSystem — objective completion', () => {
}); });
describe('QuestSystem — multi-stage progression', () => { describe('QuestSystem — multi-stage progression', () => {
it('advances through stages as objectives are satisfied', async () => { it('advances through stages as objectives are satisfied', () => {
const w = world(); const w = world();
const { vars, questLog } = makePlayer(w, [twoStageQuest()]); const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
@ -426,34 +426,34 @@ describe('QuestSystem — multi-stage progression', () => {
// satisfy stage 0 // satisfy stage 0
vars.set({ key: 'step', value: 1 }); vars.set({ key: 'step', value: 1 });
await w.update(1); w.update(1);
expect(questLog.getState('q2')?.stageIndex).toBe(1); expect(questLog.getState('q2')?.stageIndex).toBe(1);
expect(questLog.getState('q2')?.status).toBe('active'); expect(questLog.getState('q2')?.status).toBe('active');
// satisfy stage 1 // satisfy stage 1
vars.set({ key: 'step', value: 2 }); vars.set({ key: 'step', value: 2 });
await w.update(1); w.update(1);
expect(questLog.getState('q2')?.status).toBe('completed'); expect(questLog.getState('q2')?.status).toBe('completed');
}); });
it('does not skip stages', async () => { it('does not skip stages', () => {
const w = world(); const w = world();
const { vars, questLog } = makePlayer(w, [twoStageQuest()]); const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
questLog.start('q2'); questLog.start('q2');
vars.set({ key: 'step', value: 2 }); // would satisfy both stages vars.set({ key: 'step', value: 2 }); // would satisfy both stages
await w.update(1); w.update(1);
// only advances one stage per tick // only advances one stage per tick
expect(questLog.getState('q2')?.stageIndex).toBe(1); expect(questLog.getState('q2')?.stageIndex).toBe(1);
await w.update(1); // second tick completes it w.update(1); // second tick completes it
expect(questLog.getState('q2')?.status).toBe('completed'); expect(questLog.getState('q2')?.status).toBe('completed');
}); });
}); });
describe('QuestSystem — fail conditions', () => { describe('QuestSystem — fail conditions', () => {
it('fails quest when failCondition is met', async () => { it('fails quest when failCondition is met', () => {
const w = world(); const w = world();
const quest: Quest = { const quest: Quest = {
id: 'q1', id: 'q1',
@ -471,12 +471,12 @@ describe('QuestSystem — fail conditions', () => {
questLog.start('q1'); questLog.start('q1');
vars.set({ key: 'failed', value: true }); vars.set({ key: 'failed', value: true });
await w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('failed'); expect(questLog.getState('q1')?.status).toBe('failed');
}); });
it('fail condition takes priority over objective completion', async () => { it('fail condition takes priority over objective completion', () => {
const w = world(); const w = world();
const quest: Quest = { const quest: Quest = {
id: 'q1', id: 'q1',
@ -494,14 +494,14 @@ describe('QuestSystem — fail conditions', () => {
questLog.start('q1'); questLog.start('q1');
vars.set({ key: 'done', value: true }); vars.set({ key: 'done', value: true });
await w.update(1); w.update(1);
expect(questLog.getState('q1')?.status).toBe('failed'); // fail checked first expect(questLog.getState('q1')?.status).toBe('failed'); // fail checked first
}); });
}); });
describe('QuestSystem — multiple quests', () => { describe('QuestSystem — multiple quests', () => {
it('tracks multiple quests independently', async () => { it('tracks multiple quests independently', () => {
const w = world(); const w = world();
const player = w.createEntity('player'); const player = w.createEntity('player');
const vars = player.add('vars', new Variables()); const vars = player.add('vars', new Variables());
@ -520,7 +520,7 @@ describe('QuestSystem — multiple quests', () => {
log.start('q2'); log.start('q2');
vars.set({ key: 'done1', value: true }); // only q1's condition satisfied vars.set({ key: 'done1', value: true }); // only q1's condition satisfied
await w.update(1); w.update(1);
expect(log.getState('q1')?.status).toBe('completed'); expect(log.getState('q1')?.status).toBe('completed');
expect(log.getState('q2')?.status).toBe('active'); expect(log.getState('q2')?.status).toBe('active');