529 lines
20 KiB
TypeScript
529 lines
20 KiB
TypeScript
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: 'Variables(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: 'Variables(vars).step >= 1' }],
|
|
actions: [],
|
|
},
|
|
{
|
|
id: 'stage1',
|
|
description: 'Step 2',
|
|
objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables(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(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: ['Variables(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: ['Variables(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: 'Variables(vars).set' }]);
|
|
const errors = Quests.validate(quest, ['Variables(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', () => {
|
|
const w = world();
|
|
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
|
|
|
|
questLog.start('q1');
|
|
vars.set({ key: 'done', value: true });
|
|
w.update(1);
|
|
|
|
expect(questLog.getState('q1')?.status).toBe('completed');
|
|
});
|
|
|
|
it('does not complete quest while objective is unsatisfied', () => {
|
|
const w = world();
|
|
const { questLog } = makePlayer(w, [simpleQuest()]);
|
|
|
|
questLog.start('q1');
|
|
w.update(1);
|
|
|
|
expect(questLog.getState('q1')?.status).toBe('active');
|
|
});
|
|
|
|
it('does not process inactive quests', () => {
|
|
const w = world();
|
|
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
|
|
|
|
vars.set({ key: 'done', value: true });
|
|
w.update(1);
|
|
|
|
expect(questLog.getState('q1')?.status).toBe('inactive');
|
|
});
|
|
|
|
it('runs stage actions before advancing', () => {
|
|
const w = world();
|
|
// action sets vars.reward = true on the player entity
|
|
const quest = simpleQuest('q1', [{ type: 'Variables(vars).set', arg: { key: 'reward', value: true } }]);
|
|
const { vars, questLog } = makePlayer(w, [quest]);
|
|
|
|
questLog.start('q1');
|
|
vars.set({ key: 'done', value: true });
|
|
w.update(1);
|
|
|
|
expect(questLog.getState('q1')?.status).toBe('completed');
|
|
expect(vars.state.reward).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('QuestSystem — multi-stage progression', () => {
|
|
it('advances through stages as objectives are satisfied', () => {
|
|
const w = world();
|
|
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
|
|
|
|
questLog.start('q2');
|
|
|
|
// satisfy stage 0
|
|
vars.set({ key: 'step', value: 1 });
|
|
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 });
|
|
w.update(1);
|
|
expect(questLog.getState('q2')?.status).toBe('completed');
|
|
});
|
|
|
|
it('does not skip stages', () => {
|
|
const w = world();
|
|
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
|
|
|
|
questLog.start('q2');
|
|
vars.set({ key: 'step', value: 2 }); // would satisfy both stages
|
|
w.update(1);
|
|
|
|
// only advances one stage per tick
|
|
expect(questLog.getState('q2')?.stageIndex).toBe(1);
|
|
|
|
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', () => {
|
|
const w = world();
|
|
const quest: Quest = {
|
|
id: 'q1',
|
|
title: 'Timed Quest',
|
|
description: '',
|
|
stages: [{
|
|
id: 'stage0',
|
|
description: 'Do it',
|
|
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
|
|
actions: [],
|
|
failConditions: ['Variables(vars).failed == true'],
|
|
}],
|
|
};
|
|
const { vars, questLog } = makePlayer(w, [quest]);
|
|
|
|
questLog.start('q1');
|
|
vars.set({ key: 'failed', value: true });
|
|
w.update(1);
|
|
|
|
expect(questLog.getState('q1')?.status).toBe('failed');
|
|
});
|
|
|
|
it('fail condition takes priority over objective completion', () => {
|
|
const w = world();
|
|
const quest: Quest = {
|
|
id: 'q1',
|
|
title: 'Conflict Quest',
|
|
description: '',
|
|
stages: [{
|
|
id: 'stage0',
|
|
description: 'Both',
|
|
objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }],
|
|
actions: [],
|
|
failConditions: ['Variables(vars).done == true'], // same condition
|
|
}],
|
|
};
|
|
const { vars, questLog } = makePlayer(w, [quest]);
|
|
|
|
questLog.start('q1');
|
|
vars.set({ key: 'done', value: true });
|
|
w.update(1);
|
|
|
|
expect(questLog.getState('q1')?.status).toBe('failed'); // fail checked first
|
|
});
|
|
});
|
|
|
|
describe('QuestSystem — multiple quests', () => {
|
|
it('tracks multiple quests independently', () => {
|
|
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: 'Variables(vars).done1 == true' }], actions: [] }],
|
|
};
|
|
const q2: Quest = {
|
|
id: 'q2', title: 'Q2', description: '',
|
|
stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(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
|
|
w.update(1);
|
|
|
|
expect(log.getState('q1')?.status).toBe('completed');
|
|
expect(log.getState('q2')?.status).toBe('active');
|
|
});
|
|
});
|