feat(web): add quest API contracts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:05:53 +02:00
parent 81542eb5bb
commit 636efb6b5a
3 changed files with 132 additions and 0 deletions

View File

@@ -346,6 +346,7 @@ export type NpcMarker =
| 'MERCHANT'
| 'EXCHANGE'
| 'QUEST_AVAILABLE'
| 'QUEST_IN_PROGRESS'
| 'QUEST_TURN_IN';
export interface NpcSummary {
@@ -468,3 +469,55 @@ export interface ShopPurchaseResult {
silverSpent: number;
silverBalance: number;
}
/**
* Quest transport types (Playable Slice 0.9).
*
* Mirrors the API's quest DTOs field for field. Nothing here is computed
* client-side: which step is current, how far along it is and whether it is
* blocked are all decided by the server (AGENTS.md §22).
*/
export type QuestStatus = 'AVAILABLE' | 'ACTIVE' | 'COMPLETED';
export type QuestObjectiveType = 'COLLECT_ITEM' | 'TALK_TO_NPC';
export interface QuestObjectiveView {
key: string;
description: string;
type: QuestObjectiveType;
targetKey: string;
required: number;
current: number;
completed: boolean;
}
export interface QuestView {
key: string;
title: string;
description: string;
status: QuestStatus;
objectives: QuestObjectiveView[];
currentObjectiveKey: string | null;
/** Why the current step cannot progress, when it cannot (slice §4, §12). */
hint: string | null;
}
export interface GrantedLootBag {
key: string;
name: string;
lootCategory: string;
capacity: number;
}
export interface QuestInteractionResult {
quest: QuestView;
/** What the NPC says for the step just performed (slice §5, §6, §8). */
npcLine: string | null;
/** Non-null only on the step that hands a bag over (slice §12 Bag UI). */
grantedBag: GrantedLootBag | null;
consumedItems: Array<{ itemKey: string; quantity: number }>;
rewards: {
factionKey: string | null;
reputation: number;
silver: number;
} | null;
}

View File

@@ -156,4 +156,47 @@ describe('GameApiService', () => {
});
req.flush({});
});
it('reads the quest log', () => {
service.getQuests().subscribe();
const req = http.expectOne('/api/quests');
expect(req.request.method).toBe('GET');
req.flush([]);
});
it('accepts a quest without sending a body', () => {
service
.acceptQuest('south-gate-warden', 'trouble-beyond-the-gate')
.subscribe();
const req = http.expectOne(
'/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/accept',
);
expect(req.request.method).toBe('POST');
// The server decides where the quest starts; the client only names it.
expect(req.request.body).toEqual({});
req.flush({});
});
it('advances a quest step without naming the step', () => {
service
.advanceQuest('borin-quartermaster', 'trouble-beyond-the-gate')
.subscribe();
const req = http.expectOne(
'/api/npcs/borin-quartermaster/quests/trouble-beyond-the-gate/advance',
);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({});
req.flush({});
});
it('encodes npc and quest keys into the path', () => {
service.advanceQuest('odd/key', 'quest key').subscribe();
const req = http.expectOne('/api/npcs/odd%2Fkey/quests/quest%20key/advance');
expect(req.request.method).toBe('POST');
req.flush({});
});
});

View File

@@ -16,6 +16,8 @@ import {
ExchangeView,
NpcInteraction,
NpcSummary,
QuestInteractionResult,
QuestView,
ReputationEntry,
ShopPurchaseResult,
ShopView,
@@ -128,6 +130,40 @@ export class GameApiService {
);
}
getQuests(): Observable<QuestView[]> {
return this.http.get<QuestView[]>('/api/quests');
}
/**
* Takes a quest on. Only the two keys travel: the server decides whether this
* person offers it and where the quest starts (slice §10).
*/
acceptQuest(
npcKey: string,
questKey: string,
): Observable<QuestInteractionResult> {
return this.http.post<QuestInteractionResult>(
`/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/accept`,
{},
);
}
/**
* Performs whatever step this NPC is currently owed.
*
* The client never names the step. Which one is current, what it grants and
* what it consumes are the server's to decide (AGENTS.md §5).
*/
advanceQuest(
npcKey: string,
questKey: string,
): Observable<QuestInteractionResult> {
return this.http.post<QuestInteractionResult>(
`/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/advance`,
{},
);
}
getShop(merchantKey: string): Observable<ShopView> {
return this.http.get<ShopView>(
`/api/merchants/${encodeURIComponent(merchantKey)}/shop`,