feat(quests): expose the quest log and step endpoints

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:03:17 +02:00
parent 8f37c15654
commit 81542eb5bb
4 changed files with 239 additions and 0 deletions

View File

@@ -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<App>;
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<App>();
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',
);
});
});

View File

@@ -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<QuestDto[]> {
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<QuestInteractionResultDto> {
return this.questService.acceptQuest(DEMO_CHARACTER_ID, npcKey, questKey);
}
@Post('advance')
advanceQuest(
@Param('npcKey') npcKey: string,
@Param('questKey') questKey: string,
): Promise<QuestInteractionResultDto> {
return this.questService.advanceQuest(DEMO_CHARACTER_ID, npcKey, questKey);
}
}

View File

@@ -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 {}