868 lines
26 KiB
TypeScript
868 lines
26 KiB
TypeScript
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);
|
||
});
|
||
});
|