diff --git a/apps/api/src/quests/quest.errors.ts b/apps/api/src/quests/quest.errors.ts new file mode 100644 index 0000000..081c38f --- /dev/null +++ b/apps/api/src/quests/quest.errors.ts @@ -0,0 +1,81 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type QuestErrorCode = + | 'QUEST_NOT_FOUND' + | 'QUEST_NOT_OFFERED_HERE' + | 'QUEST_ALREADY_ACCEPTED' + | 'QUEST_NOT_ACTIVE' + | 'QUEST_STEP_NOT_HERE' + | 'QUEST_OBJECTIVE_INCOMPLETE'; + +export class QuestDomainError extends HttpException { + constructor( + public readonly code: QuestErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function questNotFound(): QuestDomainError { + return new QuestDomainError( + 'QUEST_NOT_FOUND', + HttpStatus.NOT_FOUND, + 'This quest could not be found.', + ); +} + +export function questNotOfferedHere(): QuestDomainError { + return new QuestDomainError( + 'QUEST_NOT_OFFERED_HERE', + HttpStatus.CONFLICT, + 'This person has nothing to ask of you.', + ); +} + +/** + * Raised when the quest is already on the character's list. + * + * Also what a lost race resolves to: the unique index on + * (character_id, quest_id) is the real guarantee, and this turns the + * constraint violation into something the client can explain (AGENTS.md §30). + */ +export function questAlreadyAccepted(): QuestDomainError { + return new QuestDomainError( + 'QUEST_ALREADY_ACCEPTED', + HttpStatus.CONFLICT, + 'You have already taken this on.', + ); +} + +export function questNotActive(): QuestDomainError { + return new QuestDomainError( + 'QUEST_NOT_ACTIVE', + HttpStatus.CONFLICT, + 'You are not on this quest.', + ); +} + +/** + * Raised when this NPC is not what the quest currently needs. + * + * Covers both "you are talking to the wrong person" and "you came back too + * early": the current step is derived, so a turn-in attempt with four pelts + * simply is not the step the character is on. + */ +export function questStepNotHere(): QuestDomainError { + return new QuestDomainError( + 'QUEST_STEP_NOT_HERE', + HttpStatus.CONFLICT, + 'This is not what the quest needs from you right now.', + ); +} + +export function questObjectiveIncomplete(): QuestDomainError { + return new QuestDomainError( + 'QUEST_OBJECTIVE_INCOMPLETE', + HttpStatus.CONFLICT, + 'You do not have what this step needs yet.', + ); +} diff --git a/apps/api/src/quests/quest.service.spec.ts b/apps/api/src/quests/quest.service.spec.ts new file mode 100644 index 0000000..c59a4ed --- /dev/null +++ b/apps/api/src/quests/quest.service.spec.ts @@ -0,0 +1,867 @@ +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); + }); +}); diff --git a/apps/api/src/quests/quest.service.ts b/apps/api/src/quests/quest.service.ts new file mode 100644 index 0000000..fec2bf6 --- /dev/null +++ b/apps/api/src/quests/quest.service.ts @@ -0,0 +1,489 @@ +import { Injectable } from '@nestjs/common'; +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 { 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 { QuestProgressService, QuestState } from './quest-progress.service'; +import { + questAlreadyAccepted, + questNotActive, + questNotFound, + questNotOfferedHere, + questObjectiveIncomplete, + questStepNotHere, +} from './quest.errors'; +import { + CharacterQuestStatus, + GrantedLootBagDto, + NpcQuestRole, + QuestConsumedItemDto, + QuestDto, + QuestInteractionResultDto, + QuestObjectiveType, + QuestRewardDto, +} from './quest.types'; + +/** Postgres' unique-violation SQLSTATE. */ +const UNIQUE_VIOLATION = '23505'; + +/** + * Runs a quest chain (Playable Slice 0.9 §10). + * + * A step-based state machine and nothing more: §10 asks for exactly that and + * warns off a branching narrative engine. What a step *does* is content on the + * objective -- set a flag, hand a bag over, consume what it asked for -- so + * this service applies effects it finds rather than switching on quest keys + * (AGENTS.md §9). + * + * Steps are triggered by their own endpoints rather than by dialogue actions + * (decision D4). NPC spec §40 sketched `START_QUEST` / `COMPLETE_QUEST` as + * dialogue actions, but there is no dialogue-response endpoint to carry them, + * and building one would be the narrative engine §10 rules out. The beat's line + * travels back on the step result instead. + * + * Every softlock case in §11 is handled by *not* storing progress: the active + * step is derived from what the character owns right now + * (`QuestProgressService`), so selling the pelts reopens the objective, owning + * some before accepting already counts, and a bag obtained some other way just + * lets the first hunt finish. + */ +@Injectable() +export class QuestService { + constructor( + private readonly dataSource: DataSource, + private readonly progress: QuestProgressService, + private readonly npcs: NpcService, + private readonly reputation: ReputationService, + ) {} + + /** Every enabled quest and where this character stands with it (spec §12). */ + async getQuestLog(characterId: string): Promise { + const states = await this.progress.getQuestStates(characterId); + return states.map((state) => toQuestDto(state)); + } + + /** + * Takes a quest on, once (spec §13). + * + * The NPC has to actually offer it: an `OFFER` assignment is what makes a + * person a quest giver, not a flag on the NPC row (NPC spec §14). + */ + async acceptQuest( + characterId: string, + npcKey: string, + questKey: string, + ): Promise { + // The character's own location decides reachability, never the request. + const npc = await this.npcs.requireReachableNpc(characterId, npcKey); + + await this.dataSource.transaction(async (manager) => { + const quest = await this.requireQuest(manager, questKey); + + const assignment = await manager + .getRepository(NpcQuestAssignment) + .findOneBy({ + npcId: npc.id, + questId: quest.id, + role: NpcQuestRole.OFFER, + enabled: true, + }); + if (!assignment) { + throw questNotOfferedHere(); + } + + const rows = manager.getRepository(CharacterQuest); + const existing = await rows.findOne({ + where: { characterId, questId: quest.id }, + }); + if (existing) { + throw questAlreadyAccepted(); + } + + try { + await rows.save( + rows.create({ + characterId, + questId: quest.id, + status: CharacterQuestStatus.ACTIVE, + currentObjectiveIndex: 0, + acceptedAt: new Date(), + completedAt: null, + }), + ); + } catch (error) { + // Two clicks can both clear the check above. The unique index is the + // real guarantee; this is what stops the loser seeing a 500. + if (!isUniqueViolation(error)) { + throw error; + } + throw questAlreadyAccepted(); + } + }); + + const state = await this.progress.getQuestState(characterId, questKey); + if (!state) { + throw questNotFound(); + } + + // No line of its own: the warden's offer is a dialogue node, and the NPC + // screen re-reads dialogue after the quest state changes. + return { + quest: toQuestDto(state), + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }; + } + + /** + * Performs the talk step the character is currently on (spec §5, §6, §8). + * + * One endpoint for all three beats, because the difference between them is + * content: the referral sets a flag, Borin's step grants a bag, and the last + * step consumes and completes. Everything runs in one transaction under a + * write lock on the quest row, so two clicks cannot both consume the pelts + * (AGENTS.md §29, §30). + */ + async advanceQuest( + characterId: string, + npcKey: string, + questKey: string, + ): Promise { + const npc = await this.npcs.requireReachableNpc(characterId, npcKey); + + return this.dataSource.transaction(async (manager) => { + const quest = await this.requireQuest(manager, questKey); + + const rows = manager.getRepository(CharacterQuest); + const row = await rows.findOne({ + where: { characterId, questId: quest.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!row || row.status !== CharacterQuestStatus.ACTIVE) { + throw questNotActive(); + } + + const state = await this.progress.getQuestState( + characterId, + questKey, + manager, + ); + if (!state || state.currentIndex === null) { + throw questNotActive(); + } + + const currentIndex = state.currentIndex; + const step = state.objectives[currentIndex]?.objective; + // Also what refuses an early turn-in: with the pelts still short, the + // derived step is the hunt, not the hand-over. + if ( + !step || + step.type !== QuestObjectiveType.TALK_TO_NPC || + step.targetKey !== npc.key + ) { + throw questStepNotHere(); + } + + row.currentObjectiveIndex = currentIndex + 1; + + const grantedBag = await this.grantLootBag(manager, characterId, step); + await this.setStepFlag(manager, characterId, step); + + let consumedItems: QuestConsumedItemDto[] = []; + let rewards: QuestRewardDto | null = null; + + if (row.currentObjectiveIndex >= state.objectives.length) { + consumedItems = await this.consumeQuestItems( + manager, + characterId, + state, + ); + rewards = await this.grantRewards(manager, characterId, quest); + row.status = CharacterQuestStatus.COMPLETED; + row.completedAt = new Date(); + } + + await rows.save(row); + + const refreshed = await this.progress.getQuestState( + characterId, + questKey, + manager, + ); + + return { + quest: toQuestDto(refreshed ?? state), + npcLine: step.npcLine, + grantedBag, + consumedItems, + rewards, + }; + }); + } + + private async requireQuest( + manager: EntityManager, + questKey: string, + ): Promise { + const quest = await manager + .getRepository(QuestDefinition) + .findOneBy({ key: questKey, enabled: true }); + if (!quest) { + throw questNotFound(); + } + return quest; + } + + /** + * Hands the step's bag over, at most once (spec §11, decision D1). + * + * A bag the character already holds is not an error: development data, a + * repeated click and a re-run of the same step must all leave the chain + * moving. Only the roomiest active bag per category counts anyway + * (Slice 0.7.5 §6), so a second copy would grant nothing. + */ + private async grantLootBag( + manager: EntityManager, + characterId: string, + step: QuestObjective, + ): Promise { + if (!step.grantsLootBagKey) { + return null; + } + + const definition = await manager + .getRepository(LootBagDefinition) + .findOneBy({ key: step.grantsLootBagKey }); + if (!definition) { + // Content names a bag that does not exist. Refusing the whole step would + // strand the player on a content bug they cannot fix; the step still + // counts and the missing bag is visible in its absence. + return null; + } + + const bags = manager.getRepository(CharacterLootBag); + const existing = await bags.findOne({ + where: { characterId, lootBagDefinitionId: definition.id }, + }); + if (existing) { + return null; + } + + await bags.save( + bags.create({ + characterId, + lootBagDefinitionId: definition.id, + active: true, + }), + ); + + return { + key: definition.key, + name: definition.name, + lootCategory: definition.lootCategory, + capacity: definition.capacity, + }; + } + + /** + * Writes the step's dialogue flag onto whichever NPC content names (spec §5). + * + * The warden's referral belongs on Borin's row, because a flag is per-NPC + * player state (NPC spec §7) and Borin is the NPC in context when the Hide + * Bag offer's 0.8.5 bypass is evaluated (spec §6). Merged into the existing + * flags rather than replacing them -- `met` drives Borin's first-meeting + * line, and dropping it would make him greet the player as a stranger again. + */ + private async setStepFlag( + manager: EntityManager, + characterId: string, + step: QuestObjective, + ): Promise { + if (!step.setsFlagKey || !step.setsFlagNpcKey) { + return; + } + + const target = await manager + .getRepository(NpcDefinition) + .findOneBy({ key: step.setsFlagNpcKey }); + if (!target) { + return; + } + + const states = manager.getRepository(CharacterNpcState); + const existing = await states.findOneBy({ characterId, npcId: target.id }); + + if (existing) { + existing.flags = { ...existing.flags, [step.setsFlagKey]: true }; + await states.save(existing); + return; + } + + await states.save( + states.create({ + characterId, + npcId: target.id, + flags: { [step.setsFlagKey]: true }, + }), + ); + } + + /** + * Takes the goods the quest asked for (spec §8). + * + * Only steps marked `consumeOnComplete` are taken: this chain asks for five + * pelts twice, and consuming both would quietly demand ten. Quantities are + * re-checked here rather than trusted from the derivation, because the + * derivation ran before the lock and the player may have traded in between. + */ + private async consumeQuestItems( + manager: EntityManager, + characterId: string, + state: QuestState, + ): Promise { + const consumed: QuestConsumedItemDto[] = []; + + for (const entry of state.objectives) { + const step = entry.objective; + if ( + step.type !== QuestObjectiveType.COLLECT_ITEM || + !step.consumeOnComplete + ) { + continue; + } + + const definition = await manager + .getRepository(ItemDefinition) + .findOneBy({ key: step.targetKey }); + if (!definition) { + throw questObjectiveIncomplete(); + } + + const characterItems = manager.getRepository(CharacterItem); + const owned = await characterItems.findOne({ + where: { characterId, itemDefinitionId: definition.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!owned || owned.quantity < step.requiredQuantity) { + throw questObjectiveIncomplete(); + } + + if (owned.quantity === step.requiredQuantity) { + await characterItems.remove(owned); + } else { + owned.quantity -= step.requiredQuantity; + await characterItems.save(owned); + } + + consumed.push({ + itemKey: step.targetKey, + quantity: step.requiredQuantity, + }); + } + + return consumed; + } + + /** + * Pays the quest out, once (spec §9). + * + * Reputation and Silver only. There is no XP anywhere in the project any + * more (Slice 0.7 V2 §7), and no renown milestone on this quest by decision + * D2 -- one would reach World Renown 3 and open a Slice 0.8.5 offer that is + * meant to stay out of reach until 0.11. The seeded values pay 10 reputation + * and no Silver (D3); both branches exist so retuning stays a content change. + */ + private async grantRewards( + manager: EntityManager, + characterId: string, + quest: QuestDefinition, + ): Promise { + if (quest.rewardFactionKey && quest.rewardReputation > 0) { + await this.reputation.grantReputation( + characterId, + quest.rewardFactionKey, + quest.rewardReputation, + manager, + ); + } + + if (quest.rewardSilver > 0) { + const characters = manager.getRepository(Character); + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (character) { + character.silver += quest.rewardSilver; + await characters.save(character); + } + } + + return { + factionKey: quest.rewardFactionKey, + reputation: quest.rewardReputation, + silver: quest.rewardSilver, + }; + } +} + +/** + * The quest as the player reads it (spec §12). + * + * `completed` is positional rather than stored: everything behind the derived + * step is done, and everything from it onward is not. A finished quest reads as + * done throughout, which is what keeps the journal honest after the goods were + * consumed. + */ +export function toQuestDto(state: QuestState): QuestDto { + const currentIndex = state.currentIndex; + const currentEntry = + currentIndex === null ? undefined : state.objectives[currentIndex]; + + return { + key: state.quest.key, + title: state.quest.title, + description: state.quest.description, + status: state.status, + objectives: state.objectives.map((entry, index) => ({ + key: entry.objective.key, + description: entry.objective.description, + type: entry.objective.type, + targetKey: entry.objective.targetKey, + required: entry.required, + current: entry.current, + completed: + state.status === 'COMPLETED' || + (currentIndex !== null && index < currentIndex), + })), + currentObjectiveKey: currentEntry?.objective.key ?? null, + // Only while the step genuinely cannot progress. A step that is merely + // unfinished needs no explanation, and saying one anyway would train the + // player to ignore the line that matters. + hint: + currentEntry && currentEntry.blocked && !currentEntry.satisfied + ? currentEntry.objective.hintText + : null, + }; +} + +function isUniqueViolation(error: unknown): boolean { + const code = ( + error as { driverError?: { code?: string }; code?: string } | null + )?.driverError?.code; + return ( + code === UNIQUE_VIOLATION || + (error as { code?: string } | null)?.code === UNIQUE_VIOLATION + ); +}