Remove async actions
This commit is contained in:
parent
6d5cd0b2cc
commit
d7a3830740
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue