Files
ashen-realms/apps/api/src/quests/quest.service.spec.ts
2026-08-22 22:54:44 +02:00

868 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { LootCategory } from '../items/loot-category.enum';
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { NpcDefinition } from '../npcs/entities/npc-definition.entity';
import { NpcService } from '../npcs/npc.service';
import { ReputationService } from '../reputation/reputation.service';
import { CharacterQuest } from './entities/character-quest.entity';
import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity';
import { QuestDefinition } from './entities/quest-definition.entity';
import { QuestObjective } from './entities/quest-objective.entity';
import {
QuestObjectiveState,
QuestProgressService,
QuestState,
} from './quest-progress.service';
import { QuestService } from './quest.service';
import {
CharacterQuestStatus,
NpcQuestRole,
QuestObjectiveType,
QuestStatus,
} from './quest.types';
const CHARACTER_ID = 'character-1';
const QUEST_ID = 'quest-1';
const QUEST_KEY = 'trouble-beyond-the-gate';
const WARDEN_ID = 'npc-warden';
const WARDEN_KEY = 'south-gate-warden';
const BORIN_ID = 'npc-borin';
const BORIN_KEY = 'borin-quartermaster';
const REFERRAL_FLAG = 'referred-by-south-gate-warden';
const PELT_DEFINITION_ID = 'item-pelt';
const HIDE_BAG_DEFINITION_ID = 'bag-hide';
function objective(over: Partial<QuestObjective>): QuestObjective {
return {
id: `objective-${over.orderIndex ?? 0}`,
questId: QUEST_ID,
key: `step-${over.orderIndex ?? 0}`,
orderIndex: 0,
type: QuestObjectiveType.COLLECT_ITEM,
targetKey: 'ash-pelt',
requiredQuantity: 5,
description: 'Collect Ashen Pelts',
npcLine: null,
hintText: null,
advanceWhenBlocked: false,
consumeOnComplete: false,
grantsLootBagKey: null,
setsFlagKey: null,
setsFlagNpcKey: null,
enabled: true,
...over,
} as QuestObjective;
}
/** The real five-step chain (Slice 0.9 §3§8). */
function chain(): QuestObjective[] {
return [
objective({
orderIndex: 0,
key: 'collect-pelts-first',
advanceWhenBlocked: true,
hintText: 'You cannot carry enough pelts. Return to the South Gate Warden.',
}),
objective({
orderIndex: 1,
key: 'report-capacity',
type: QuestObjectiveType.TALK_TO_NPC,
targetKey: WARDEN_KEY,
requiredQuantity: 1,
description: 'Return to the South Gate Warden',
npcLine: 'Go see Borin in Graufurt.',
setsFlagKey: REFERRAL_FLAG,
setsFlagNpcKey: BORIN_KEY,
}),
objective({
orderIndex: 2,
key: 'collect-bag',
type: QuestObjectiveType.TALK_TO_NPC,
targetKey: BORIN_KEY,
requiredQuantity: 1,
description: 'Speak with Borin in Graufurt',
npcLine: 'Take this.',
grantsLootBagKey: 'basic-hide-bag',
}),
objective({
orderIndex: 3,
key: 'collect-pelts',
consumeOnComplete: true,
}),
objective({
orderIndex: 4,
key: 'turn-in',
type: QuestObjectiveType.TALK_TO_NPC,
targetKey: WARDEN_KEY,
requiredQuantity: 1,
description: 'Bring the pelts to the South Gate Warden',
npcLine: "Good. That's enough for me.",
}),
];
}
interface Fixture {
/** Which NPC the character is standing with; null means unreachable. */
standingWith?: 'warden' | 'borin' | null;
questEnabled?: boolean;
hasOfferAssignment?: boolean;
/** Undefined means the quest was never accepted. */
status?: CharacterQuestStatus;
storedIndex?: number;
/** The step the derivation says the character is on. */
currentIndex?: number | null;
pelts?: number;
blockedIndexes?: number[];
ownsHideBag?: boolean;
borinFlags?: Record<string, boolean | string | number>;
silver?: number;
rewardReputation?: number;
rewardSilver?: number;
/** Makes the insert fail the way a lost unique-index race would. */
insertRace?: boolean;
}
function createWorld(fixture: Fixture = {}) {
const quest = {
id: QUEST_ID,
key: QUEST_KEY,
title: 'Trouble Beyond the Gate',
description: 'Five pelts.',
rewardFactionKey: 'border-guard',
rewardReputation: fixture.rewardReputation ?? 10,
rewardSilver: fixture.rewardSilver ?? 0,
enabled: fixture.questEnabled ?? true,
} as QuestDefinition;
const objectives = chain();
const npcs: Record<string, NpcDefinition> = {
warden: { id: WARDEN_ID, key: WARDEN_KEY } as NpcDefinition,
borin: { id: BORIN_ID, key: BORIN_KEY } as NpcDefinition,
};
const questRow: CharacterQuest | null =
fixture.status === undefined
? null
: ({
id: 'character-quest-1',
characterId: CHARACTER_ID,
questId: QUEST_ID,
status: fixture.status,
currentObjectiveIndex: fixture.storedIndex ?? 0,
acceptedAt: new Date('2026-01-01T00:00:00Z'),
completedAt: null,
} as CharacterQuest);
const character = {
id: CHARACTER_ID,
silver: fixture.silver ?? 0,
} as Character;
const peltStack =
(fixture.pelts ?? 0) > 0
? ({
id: 'character-item-pelt',
characterId: CHARACTER_ID,
itemDefinitionId: PELT_DEFINITION_ID,
quantity: fixture.pelts as number,
} as CharacterItem)
: null;
const state = {
characterItems: peltStack ? [peltStack] : ([] as CharacterItem[]),
lootBags: fixture.ownsHideBag
? [
{
characterId: CHARACTER_ID,
lootBagDefinitionId: HIDE_BAG_DEFINITION_ID,
active: true,
},
]
: ([] as Array<Record<string, unknown>>),
npcStates: fixture.borinFlags
? [
{
characterId: CHARACTER_ID,
npcId: BORIN_ID,
flags: { ...fixture.borinFlags },
},
]
: ([] as Array<Record<string, unknown>>),
questRows: questRow ? [questRow] : ([] as CharacterQuest[]),
savedQuestRows: [] as CharacterQuest[],
savedNpcStates: [] as Array<Record<string, unknown>>,
savedLootBags: [] as Array<Record<string, unknown>>,
removedItems: [] as CharacterItem[],
savedCharacters: [] as Character[],
};
const manager = {
getRepository: (entity: unknown) => {
if (entity === QuestDefinition) {
return {
findOneBy: async (criteria: { key: string; enabled: boolean }) =>
quest.key === criteria.key && quest.enabled === criteria.enabled
? quest
: null,
};
}
if (entity === NpcQuestAssignment) {
return {
findOneBy: async (criteria: { npcId: string; role: NpcQuestRole }) =>
(fixture.hasOfferAssignment ?? true) &&
criteria.npcId === WARDEN_ID &&
criteria.role === NpcQuestRole.OFFER
? { id: 'assignment-1' }
: null,
};
}
if (entity === CharacterQuest) {
return {
findOne: async () => state.questRows[0] ?? null,
create: (value: Record<string, unknown>) => value,
save: async (value: CharacterQuest) => {
state.savedQuestRows.push(value);
if (fixture.insertRace && state.questRows.length === 0) {
// What Postgres raises when the unique index refuses a second
// row for the same (character, quest).
throw Object.assign(new Error('duplicate key'), {
driverError: { code: '23505' },
});
}
return value;
},
};
}
if (entity === NpcDefinition) {
return {
findOneBy: async (criteria: { key: string }) =>
Object.values(npcs).find((npc) => npc.key === criteria.key) ?? null,
};
}
if (entity === CharacterNpcState) {
return {
findOneBy: async (criteria: { npcId: string }) =>
state.npcStates.find((row) => row.npcId === criteria.npcId) ?? null,
create: (value: Record<string, unknown>) => value,
save: async (value: Record<string, unknown>) => {
state.savedNpcStates.push(value);
return value;
},
};
}
if (entity === LootBagDefinition) {
return {
findOneBy: async (criteria: { key: string }) =>
criteria.key === 'basic-hide-bag'
? {
id: HIDE_BAG_DEFINITION_ID,
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: LootCategory.HIDE,
capacity: 5,
}
: null,
};
}
if (entity === CharacterLootBag) {
return {
findOne: async () => state.lootBags[0] ?? null,
create: (value: Record<string, unknown>) => value,
save: async (value: Record<string, unknown>) => {
state.savedLootBags.push(value);
state.lootBags.push(value);
return value;
},
};
}
if (entity === ItemDefinition) {
return {
findOneBy: async (criteria: { key: string }) =>
criteria.key === 'ash-pelt'
? { id: PELT_DEFINITION_ID, key: 'ash-pelt' }
: null,
};
}
if (entity === CharacterItem) {
return {
findOne: async () => state.characterItems[0] ?? null,
save: async (value: CharacterItem) => value,
remove: async (value: CharacterItem) => {
state.removedItems.push(value);
state.characterItems = state.characterItems.filter(
(item) => item !== value,
);
return value;
},
};
}
if (entity === Character) {
return {
findOne: async () => character,
save: async (value: Character) => {
state.savedCharacters.push(value);
return value;
},
};
}
throw new Error('Unexpected repository');
},
} as unknown as EntityManager;
const dataSource = {
getRepository: manager.getRepository.bind(manager),
transaction: async <T>(run: (m: EntityManager) => Promise<T>) => run(manager),
} as unknown as DataSource;
const objectiveStates: QuestObjectiveState[] = objectives.map(
(candidate, index) => ({
objective: candidate,
current:
candidate.type === QuestObjectiveType.COLLECT_ITEM
? (fixture.pelts ?? 0)
: 0,
required: candidate.requiredQuantity,
blocked: (fixture.blockedIndexes ?? []).includes(index),
satisfied:
candidate.type === QuestObjectiveType.COLLECT_ITEM &&
(fixture.pelts ?? 0) >= candidate.requiredQuantity,
}),
);
const status: QuestStatus =
fixture.status === undefined
? 'AVAILABLE'
: fixture.status === CharacterQuestStatus.COMPLETED
? 'COMPLETED'
: 'ACTIVE';
const questState: QuestState = {
quest,
objectives: objectiveStates,
row: questRow,
status,
currentIndex:
fixture.currentIndex === undefined
? status === 'ACTIVE'
? (fixture.storedIndex ?? 0)
: null
: fixture.currentIndex,
};
const progress = {
getQuestStates: jest.fn().mockResolvedValue([questState]),
getQuestState: jest.fn().mockResolvedValue(questState),
getNpcQuestStates: jest.fn().mockResolvedValue([]),
} as unknown as QuestProgressService;
const npcService = {
requireReachableNpc: jest.fn(async (_characterId: string, key: string) => {
// Not `??`: the fixture uses an explicit null to mean "nowhere near
// anyone", which `??` would quietly turn back into the warden.
const standing =
fixture.standingWith === undefined ? 'warden' : fixture.standingWith;
if (standing === null) {
throw new Error('NPC_UNAVAILABLE');
}
if (npcs[standing].key !== key) {
throw new Error('NPC_UNAVAILABLE');
}
return npcs[standing];
}),
} as unknown as NpcService;
const reputation = {
grantReputation: jest.fn().mockResolvedValue({
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 10,
previousRank: 'NEUTRAL',
newRank: 'NEUTRAL',
rankChanged: false,
}),
} as unknown as ReputationService;
return {
service: new QuestService(dataSource, progress, npcService, reputation),
state,
character,
reputation,
questState,
};
}
describe('QuestService.acceptQuest', () => {
it('records the quest as active at the first step', async () => {
const { service, state } = createWorld({ status: undefined });
const result = await service.acceptQuest(
CHARACTER_ID,
WARDEN_KEY,
QUEST_KEY,
);
expect(state.savedQuestRows[0]).toMatchObject({
characterId: CHARACTER_ID,
questId: QUEST_ID,
status: CharacterQuestStatus.ACTIVE,
currentObjectiveIndex: 0,
});
// The offer line is dialogue content; the accept itself says nothing.
expect(result.npcLine).toBeNull();
expect(result.grantedBag).toBeNull();
});
it('refuses an NPC that does not offer the quest', async () => {
const { service } = createWorld({
status: undefined,
hasOfferAssignment: false,
});
await expect(
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_OFFERED_HERE' } });
});
it('refuses an unknown or disabled quest', async () => {
const { service } = createWorld({ status: undefined, questEnabled: false });
await expect(
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_FOUND' } });
});
it('accepts the quest only once', async () => {
const { service } = createWorld({ status: CharacterQuestStatus.ACTIVE });
await expect(
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_ALREADY_ACCEPTED' } });
});
it('turns a lost unique-index race into the same refusal', async () => {
// Two clicks that both pass the existence check: the database refuses the
// second, and the player must not see a 500 for it (AGENTS.md §30).
const { service } = createWorld({ status: undefined, insertRace: true });
await expect(
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_ALREADY_ACCEPTED' } });
});
it('never accepts a quest the character cannot reach', async () => {
const { service } = createWorld({ status: undefined, standingWith: null });
await expect(
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toThrow('NPC_UNAVAILABLE');
});
});
describe('QuestService.advanceQuest', () => {
it('sets the referral flag on Borin, not on the warden', async () => {
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 1,
pelts: 1,
blockedIndexes: [0, 3],
});
const result = await service.advanceQuest(
CHARACTER_ID,
WARDEN_KEY,
QUEST_KEY,
);
// Slice 0.9 §6: the 0.8.5 bypass is evaluated with Borin in context, so
// the flag has to live on his row (NPC spec §7).
expect(state.savedNpcStates[0]).toMatchObject({
npcId: BORIN_ID,
flags: { [REFERRAL_FLAG]: true },
});
expect(result.npcLine).toBe('Go see Borin in Graufurt.');
});
it('keeps the flags an NPC row already carried', async () => {
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 1,
pelts: 1,
borinFlags: { met: true },
});
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
// `met` drives Borin's first-meeting line. Replacing the map instead of
// merging into it would make him greet the player as a stranger again.
expect(state.savedNpcStates[0].flags).toEqual({
met: true,
[REFERRAL_FLAG]: true,
});
});
it('advances the stored floor past the step it performed', async () => {
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 1,
pelts: 1,
});
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
expect(state.savedQuestRows[0].currentObjectiveIndex).toBe(2);
});
it('hands the Basic Hide Bag over on the step at Borin', async () => {
const { service, state } = createWorld({
standingWith: 'borin',
status: CharacterQuestStatus.ACTIVE,
storedIndex: 2,
currentIndex: 2,
pelts: 1,
});
const result = await service.advanceQuest(
CHARACTER_ID,
BORIN_KEY,
QUEST_KEY,
);
expect(state.savedLootBags[0]).toMatchObject({
lootBagDefinitionId: HIDE_BAG_DEFINITION_ID,
active: true,
});
expect(result.grantedBag).toEqual({
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: LootCategory.HIDE,
capacity: 5,
});
});
it('does not hand over a bag the character already carries', async () => {
// Slice 0.9 §11: the grant is idempotent, so development data or a repeat
// click cannot produce a second bag or an error.
const { service, state } = createWorld({
standingWith: 'borin',
status: CharacterQuestStatus.ACTIVE,
storedIndex: 2,
currentIndex: 2,
ownsHideBag: true,
});
const result = await service.advanceQuest(
CHARACTER_ID,
BORIN_KEY,
QUEST_KEY,
);
expect(state.savedLootBags).toHaveLength(0);
expect(result.grantedBag).toBeNull();
// The step still counts: the chain must not stall on an already-owned bag.
expect(state.savedQuestRows[0].currentObjectiveIndex).toBe(3);
});
it('refuses a step at the wrong NPC', async () => {
const { service } = createWorld({
standingWith: 'borin',
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 1,
});
await expect(
service.advanceQuest(CHARACTER_ID, BORIN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_STEP_NOT_HERE' } });
});
it('refuses the turn-in while the pelts are still short', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 3,
pelts: 4,
});
await expect(
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_STEP_NOT_HERE' } });
});
it('refuses to advance a quest that was never accepted', async () => {
const { service } = createWorld({ status: undefined });
await expect(
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_ACTIVE' } });
});
it('refuses to advance a completed quest', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.COMPLETED,
storedIndex: 5,
});
await expect(
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_ACTIVE' } });
});
it('consumes exactly the pelts the quest asked for', async () => {
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 7,
});
const result = await service.advanceQuest(
CHARACTER_ID,
WARDEN_KEY,
QUEST_KEY,
);
// Two collect steps ask for five each; only the second consumes, so the
// turn-in takes five and leaves the surplus (§8).
expect(result.consumedItems).toEqual([{ itemKey: 'ash-pelt', quantity: 5 }]);
expect(state.characterItems[0].quantity).toBe(2);
expect(state.removedItems).toHaveLength(0);
});
it('removes the item row when the last pelt is consumed', async () => {
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 5,
});
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
expect(state.removedItems).toHaveLength(1);
});
it('refuses the turn-in when the pelts vanished between read and write', async () => {
// The derived step said "turn-in", but the transaction is where ownership
// is actually decided.
const { service } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 0,
});
await expect(
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
).rejects.toMatchObject({
response: { code: 'QUEST_OBJECTIVE_INCOMPLETE' },
});
});
it('completes the quest and stamps the time', async () => {
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 5,
});
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
const saved = state.savedQuestRows[0];
expect(saved.status).toBe(CharacterQuestStatus.COMPLETED);
expect(saved.completedAt).toBeInstanceOf(Date);
expect(saved.currentObjectiveIndex).toBe(5);
});
it('pays the regional reputation reward on completion', async () => {
const { service, reputation } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 5,
});
const result = await service.advanceQuest(
CHARACTER_ID,
WARDEN_KEY,
QUEST_KEY,
);
expect(reputation.grantReputation).toHaveBeenCalledWith(
CHARACTER_ID,
'border-guard',
10,
expect.anything(),
);
expect(result.rewards).toEqual({
factionKey: 'border-guard',
reputation: 10,
silver: 0,
});
});
it('pays no silver, because the content asks for none', async () => {
// Slice 0.9 decision D3. The branch exists so retuning is a seed change,
// but the seeded quest deliberately pays nothing.
const { service, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 5,
});
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
expect(state.savedCharacters).toHaveLength(0);
});
it('pays silver when content asks for it', async () => {
const { service, state, character } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 4,
pelts: 5,
rewardSilver: 25,
silver: 4,
});
const result = await service.advanceQuest(
CHARACTER_ID,
WARDEN_KEY,
QUEST_KEY,
);
expect(character.silver).toBe(29);
expect(state.savedCharacters).toHaveLength(1);
expect(result.rewards?.silver).toBe(25);
});
it('pays nothing on a step that is not the last one', async () => {
const { service, reputation, state } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 1,
pelts: 1,
});
const result = await service.advanceQuest(
CHARACTER_ID,
WARDEN_KEY,
QUEST_KEY,
);
expect(reputation.grantReputation).not.toHaveBeenCalled();
expect(result.rewards).toBeNull();
expect(result.consumedItems).toEqual([]);
expect(state.savedQuestRows[0].status).toBe(CharacterQuestStatus.ACTIVE);
});
});
describe('QuestService.getQuestLog', () => {
it('renders collect progress against what the step needs', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 0,
pelts: 1,
});
const [quest] = await service.getQuestLog(CHARACTER_ID);
expect(quest).toMatchObject({
key: QUEST_KEY,
title: 'Trouble Beyond the Gate',
status: 'ACTIVE',
currentObjectiveKey: 'collect-pelts-first',
});
expect(quest.objectives[0]).toMatchObject({
description: 'Collect Ashen Pelts',
current: 1,
required: 5,
completed: false,
});
});
it('shows the hint while the active step is blocked', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 0,
pelts: 1,
blockedIndexes: [0],
});
const [quest] = await service.getQuestLog(CHARACTER_ID);
// Slice 0.9 §4/§12: the line that keeps "1 / 5" from reading as a wall.
expect(quest.hint).toBe(
'You cannot carry enough pelts. Return to the South Gate Warden.',
);
});
it('shows no hint for a step that is merely unfinished', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 0,
currentIndex: 0,
pelts: 1,
});
const [quest] = await service.getQuestLog(CHARACTER_ID);
expect(quest.hint).toBeNull();
});
it('marks every step behind the current one as done', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.ACTIVE,
storedIndex: 3,
currentIndex: 3,
pelts: 1,
});
const [quest] = await service.getQuestLog(CHARACTER_ID);
expect(quest.objectives.map((entry) => entry.completed)).toEqual([
true,
true,
true,
false,
false,
]);
});
it('marks a completed quest as done throughout and points at no step', async () => {
const { service } = createWorld({
status: CharacterQuestStatus.COMPLETED,
storedIndex: 5,
});
const [quest] = await service.getQuestLog(CHARACTER_ID);
expect(quest.status).toBe('COMPLETED');
expect(quest.currentObjectiveKey).toBeNull();
expect(quest.hint).toBeNull();
expect(quest.objectives.every((entry) => entry.completed)).toBe(true);
});
it('reports an unstarted quest as available with nothing done', async () => {
const { service } = createWorld({ status: undefined });
const [quest] = await service.getQuestLog(CHARACTER_ID);
expect(quest.status).toBe('AVAILABLE');
expect(quest.currentObjectiveKey).toBeNull();
expect(quest.objectives.every((entry) => !entry.completed)).toBe(true);
});
});