diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index f05f567..a0a470a 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -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; +} diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index ea34b82..f82fafc 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -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({}); + }); }); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index 90e0b79..fc5c252 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -16,6 +16,8 @@ import { ExchangeView, NpcInteraction, NpcSummary, + QuestInteractionResult, + QuestView, ReputationEntry, ShopPurchaseResult, ShopView, @@ -128,6 +130,40 @@ export class GameApiService { ); } + getQuests(): Observable { + return this.http.get('/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 { + return this.http.post( + `/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 { + return this.http.post( + `/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/advance`, + {}, + ); + } + getShop(merchantKey: string): Observable { return this.http.get( `/api/merchants/${encodeURIComponent(merchantKey)}/shop`,