1
0
Fork 0
tsgames/test/common/rpg/quest.test.ts

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.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');
});
});