feat(quests): expose the quest log and step endpoints
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { HuntingModule } from './hunting/hunting.module';
|
|||||||
import { InventoryModule } from './inventory/inventory.module';
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
import { LootBagsModule } from './loot-bags/loot-bags.module';
|
import { LootBagsModule } from './loot-bags/loot-bags.module';
|
||||||
import { NpcsModule } from './npcs/npcs.module';
|
import { NpcsModule } from './npcs/npcs.module';
|
||||||
|
import { QuestsModule } from './quests/quests.module';
|
||||||
import { RenownModule } from './renown/renown.module';
|
import { RenownModule } from './renown/renown.module';
|
||||||
import { ReputationModule } from './reputation/reputation.module';
|
import { ReputationModule } from './reputation/reputation.module';
|
||||||
import { ShopsModule } from './shops/shops.module';
|
import { ShopsModule } from './shops/shops.module';
|
||||||
@@ -34,6 +35,7 @@ import { WorldModule } from './world/world.module';
|
|||||||
NpcsModule,
|
NpcsModule,
|
||||||
ShopsModule,
|
ShopsModule,
|
||||||
ExchangesModule,
|
ExchangesModule,
|
||||||
|
QuestsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
135
apps/api/src/quests/quest.controller.spec.ts
Normal file
135
apps/api/src/quests/quest.controller.spec.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/api/src/quests/quest.controller.ts
Normal file
52
apps/api/src/quests/quest.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/src/quests/quests.module.ts
Normal file
50
apps/api/src/quests/quests.module.ts
Normal 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 {}
|
||||||
Reference in New Issue
Block a user