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.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.step >= 1' }], actions: [], }, { id: 'stage1', description: 'Step 2', objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables.step >= 2' }], actions: [], }, ], }; } function makePlayer(w: World, quests: Quest[] = []) { const player = w.createEntity('player'); const vars = player.add(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.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.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.set' }]); const errors = Quests.validate(quest, ['Variables.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.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.done == true' }], actions: [], failConditions: ['Variables.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.done == true' }], actions: [], failConditions: ['Variables.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(new Variables()); const q1: Quest = { id: 'q1', title: 'Q1', description: '', stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables.done1 == true' }], actions: [] }], }; const q2: Quest = { id: 'q2', title: 'Q2', description: '', stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables.done2 == true' }], actions: [] }], }; const log = player.add(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'); }); });