diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 23c9278..e50686f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { HuntingModule } from './hunting/hunting.module'; import { InventoryModule } from './inventory/inventory.module'; import { LootBagsModule } from './loot-bags/loot-bags.module'; import { NpcsModule } from './npcs/npcs.module'; +import { QuestsModule } from './quests/quests.module'; import { RenownModule } from './renown/renown.module'; import { ReputationModule } from './reputation/reputation.module'; import { ShopsModule } from './shops/shops.module'; @@ -34,6 +35,7 @@ import { WorldModule } from './world/world.module'; NpcsModule, ShopsModule, ExchangesModule, + QuestsModule, ], }) export class AppModule {} diff --git a/apps/api/src/quests/quest.controller.spec.ts b/apps/api/src/quests/quest.controller.spec.ts new file mode 100644 index 0000000..442573c --- /dev/null +++ b/apps/api/src/quests/quest.controller.spec.ts @@ -0,0 +1,135 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { configureApplication } from '../app.config'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { NpcQuestController, QuestController } from './quest.controller'; +import { QuestService } from './quest.service'; +import { QuestObjectiveType } from './quest.types'; + +const QUEST = { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + status: 'ACTIVE' as const, + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: null, +}; + +describe('quest controllers', () => { + let app: INestApplication; + const getQuestLog = jest.fn(); + const acceptQuest = jest.fn(); + const advanceQuest = jest.fn(); + + beforeEach(async () => { + getQuestLog.mockReset(); + acceptQuest.mockReset(); + advanceQuest.mockReset(); + + const module = await Test.createTestingModule({ + controllers: [QuestController, NpcQuestController], + providers: [ + { + provide: QuestService, + useValue: { getQuestLog, acceptQuest, advanceQuest }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + configureApplication(app); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('returns the quest log for the session character', async () => { + getQuestLog.mockResolvedValue([QUEST]); + + const response = await request(app.getHttpServer()) + .get('/api/quests') + .expect(200); + + expect(response.body).toEqual([QUEST]); + expect(getQuestLog).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + }); + + it('accepts a quest through the NPC that offers it', async () => { + acceptQuest.mockResolvedValue({ + quest: QUEST, + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }); + + await request(app.getHttpServer()) + .post('/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/accept') + .expect(201); + + expect(acceptQuest).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'south-gate-warden', + 'trouble-beyond-the-gate', + ); + }); + + it('advances a quest step at an NPC', async () => { + advanceQuest.mockResolvedValue({ + quest: QUEST, + npcLine: 'Go see Borin in Graufurt.', + grantedBag: null, + consumedItems: [], + rewards: null, + }); + + const response = await request(app.getHttpServer()) + .post('/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/advance') + .expect(201); + + expect(response.body.npcLine).toBe('Go see Borin in Graufurt.'); + expect(advanceQuest).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'south-gate-warden', + 'trouble-beyond-the-gate', + ); + }); + + it('never lets the request name the character', async () => { + // The body is ignored entirely: which step is current, what it grants and + // what it consumes are the server's to decide (AGENTS.md §5). + advanceQuest.mockResolvedValue({ + quest: QUEST, + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }); + + await request(app.getHttpServer()) + .post('/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/advance') + .send({ characterId: 'somebody-else', objectiveKey: 'turn-in' }) + .expect(201); + + expect(advanceQuest).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'south-gate-warden', + 'trouble-beyond-the-gate', + ); + }); +}); diff --git a/apps/api/src/quests/quest.controller.ts b/apps/api/src/quests/quest.controller.ts new file mode 100644 index 0000000..8fdb2c2 --- /dev/null +++ b/apps/api/src/quests/quest.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Param, Post } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { QuestService } from './quest.service'; +import { QuestDto, QuestInteractionResultDto } from './quest.types'; + +/** + * The character's quest log (Playable Slice 0.9 §12). + * + * The character comes from the session stand-in, never from the request, so a + * caller cannot read somebody else's quests. + */ +@Controller('quests') +export class QuestController { + constructor(private readonly questService: QuestService) {} + + @Get() + getQuestLog(): Promise { + return this.questService.getQuestLog(DEMO_CHARACTER_ID); + } +} + +/** + * The two things a player can do to a quest at an NPC (spec §3, §5, §6, §8). + * + * Neither route takes a body. Which step is current, what it grants and what it + * consumes are all the server's to decide (AGENTS.md §5) -- the request only + * names who is being spoken to and about what. + * + * Deliberately not dialogue actions (decision D4): carrying `START_QUEST` out + * through the dialogue tree would need a response-selection endpoint and an + * action executor, which is the branching narrative engine §10 rules out. + */ +@Controller('npcs/:npcKey/quests/:questKey') +export class NpcQuestController { + constructor(private readonly questService: QuestService) {} + + @Post('accept') + acceptQuest( + @Param('npcKey') npcKey: string, + @Param('questKey') questKey: string, + ): Promise { + return this.questService.acceptQuest(DEMO_CHARACTER_ID, npcKey, questKey); + } + + @Post('advance') + advanceQuest( + @Param('npcKey') npcKey: string, + @Param('questKey') questKey: string, + ): Promise { + return this.questService.advanceQuest(DEMO_CHARACTER_ID, npcKey, questKey); + } +} diff --git a/apps/api/src/quests/quests.module.ts b/apps/api/src/quests/quests.module.ts new file mode 100644 index 0000000..8eb516e --- /dev/null +++ b/apps/api/src/quests/quests.module.ts @@ -0,0 +1,50 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/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 { NpcsModule } from '../npcs/npcs.module'; +import { ReputationModule } from '../reputation/reputation.module'; +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 { QuestProgressModule } from './quest-progress.module'; +import { NpcQuestController, QuestController } from './quest.controller'; +import { QuestService } from './quest.service'; + +/** + * Running quest chains (Playable Slice 0.9 §10). + * + * Imports `NpcsModule` for reachability and `QuestProgressModule` for the + * derived step. `NpcsModule` imports only the latter, which is why that split + * exists at all -- see `QuestProgressModule`. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Character, + CharacterItem, + CharacterLootBag, + CharacterNpcState, + CharacterQuest, + ItemDefinition, + LootBagDefinition, + NpcDefinition, + NpcQuestAssignment, + QuestDefinition, + QuestObjective, + ]), + QuestProgressModule, + NpcsModule, + ReputationModule, + ], + controllers: [QuestController, NpcQuestController], + providers: [QuestService], + exports: [QuestService], +}) +export class QuestsModule {}