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 { 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; 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 = { 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>), npcStates: fixture.borinFlags ? [ { characterId: CHARACTER_ID, npcId: BORIN_ID, flags: { ...fixture.borinFlags }, }, ] : ([] as Array>), questRows: questRow ? [questRow] : ([] as CharacterQuest[]), savedQuestRows: [] as CharacterQuest[], savedNpcStates: [] as Array>, savedLootBags: [] as Array>, 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) => 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) => value, save: async (value: Record) => { 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) => value, save: async (value: Record) => { 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 (run: (m: EntityManager) => Promise) => 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); }); });