feat(web): add quest API contracts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -346,6 +346,7 @@ export type NpcMarker =
|
|||||||
| 'MERCHANT'
|
| 'MERCHANT'
|
||||||
| 'EXCHANGE'
|
| 'EXCHANGE'
|
||||||
| 'QUEST_AVAILABLE'
|
| 'QUEST_AVAILABLE'
|
||||||
|
| 'QUEST_IN_PROGRESS'
|
||||||
| 'QUEST_TURN_IN';
|
| 'QUEST_TURN_IN';
|
||||||
|
|
||||||
export interface NpcSummary {
|
export interface NpcSummary {
|
||||||
@@ -468,3 +469,55 @@ export interface ShopPurchaseResult {
|
|||||||
silverSpent: number;
|
silverSpent: number;
|
||||||
silverBalance: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,4 +156,47 @@ describe('GameApiService', () => {
|
|||||||
});
|
});
|
||||||
req.flush({});
|
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({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
ExchangeView,
|
ExchangeView,
|
||||||
NpcInteraction,
|
NpcInteraction,
|
||||||
NpcSummary,
|
NpcSummary,
|
||||||
|
QuestInteractionResult,
|
||||||
|
QuestView,
|
||||||
ReputationEntry,
|
ReputationEntry,
|
||||||
ShopPurchaseResult,
|
ShopPurchaseResult,
|
||||||
ShopView,
|
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> {
|
getShop(merchantKey: string): Observable<ShopView> {
|
||||||
return this.http.get<ShopView>(
|
return this.http.get<ShopView>(
|
||||||
`/api/merchants/${encodeURIComponent(merchantKey)}/shop`,
|
`/api/merchants/${encodeURIComponent(merchantKey)}/shop`,
|
||||||
|
|||||||
Reference in New Issue
Block a user