From bc0d91019027d59040f2f67e95ad992de777bdac Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 23:12:09 +0200 Subject: [PATCH] feat(web): add the quest journal Co-Authored-By: Claude Opus 5 --- apps/web/src/app/app.routes.ts | 7 + apps/web/src/app/app.spec.ts | 16 +- .../quest-objective-line.component.html | 14 ++ .../quest-objective-line.component.scss | 43 ++++++ .../quests/quest-objective-line.component.ts | 31 ++++ .../features/quests/quest-page.component.html | 52 +++++++ .../features/quests/quest-page.component.scss | 71 +++++++++ .../quests/quest-page.component.spec.ts | 139 ++++++++++++++++++ .../features/quests/quest-page.component.ts | 38 +++++ .../app/features/quests/quest.store.spec.ts | 108 ++++++++++++++ .../src/app/features/quests/quest.store.ts | 83 +++++++++++ .../side-navigation.component.html | 7 +- 12 files changed, 602 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/features/quests/quest-objective-line.component.html create mode 100644 apps/web/src/app/features/quests/quest-objective-line.component.scss create mode 100644 apps/web/src/app/features/quests/quest-objective-line.component.ts create mode 100644 apps/web/src/app/features/quests/quest-page.component.html create mode 100644 apps/web/src/app/features/quests/quest-page.component.scss create mode 100644 apps/web/src/app/features/quests/quest-page.component.spec.ts create mode 100644 apps/web/src/app/features/quests/quest-page.component.ts create mode 100644 apps/web/src/app/features/quests/quest.store.spec.ts create mode 100644 apps/web/src/app/features/quests/quest.store.ts diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index ff29e04..b4db3ef 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -42,6 +42,13 @@ export const routes: Routes = [ (module) => module.MerchantPageComponent, ), }, + { + path: 'quests', + loadComponent: () => + import('./features/quests/quest-page.component').then( + (module) => module.QuestPageComponent, + ), + }, { path: 'inventory', loadComponent: () => diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index dcd4ea0..6f85984 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -67,11 +67,17 @@ describe('App', () => { expect(inventoryButton?.disabled).toBe(false); expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventory'); - for (const destination of ['quests', 'character']) { - expect( - element.querySelector(`[data-navigation="${destination}"]`)?.disabled, - ).toBe(true); - } + // Quests became reachable in Slice 0.9; Character is still unbuilt. + const questsButton = element.querySelector( + '[data-navigation="quests"]', + ); + expect(questsButton?.disabled).toBe(false); + expect(questsButton?.getAttribute('aria-label')).toBe('Quests'); + + expect( + element.querySelector('[data-navigation="character"]') + ?.disabled, + ).toBe(true); expect(element.textContent).not.toContain('Shop'); }); diff --git a/apps/web/src/app/features/quests/quest-objective-line.component.html b/apps/web/src/app/features/quests/quest-objective-line.component.html new file mode 100644 index 0000000..9fd7d73 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-objective-line.component.html @@ -0,0 +1,14 @@ +

+ {{ objective.description }} + @if (showsProgress) { + {{ objective.current }} / {{ objective.required }} + } +

+@if (hint) { +

{{ hint }}

+} diff --git a/apps/web/src/app/features/quests/quest-objective-line.component.scss b/apps/web/src/app/features/quests/quest-objective-line.component.scss new file mode 100644 index 0000000..966ff99 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-objective-line.component.scss @@ -0,0 +1,43 @@ +:host { + display: block; +} + +/* Description left, tally right, so a column of objectives reads as a list of + counts rather than a paragraph. */ +.quest-objective { + display: flex; + gap: var(--ar-space-3); + align-items: baseline; + justify-content: space-between; + margin: 0; + color: var(--ar-text); + font-size: 0.95rem; +} + +/* Done steps stay visible but stop competing for attention. */ +.quest-objective--done { + color: var(--ar-text-muted); + text-decoration: line-through; + text-decoration-color: rgb(155 122 66 / 0.5); +} + +.quest-objective__progress { + color: var(--ar-gold); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.quest-objective--done .quest-objective__progress { + color: var(--ar-text-muted); +} + +/* The line that keeps "1 / 5" from reading as a dead end (slice §4). Warning + amber rather than danger red: the player is not in trouble, just stuck. */ +.quest-objective__hint { + margin: var(--ar-space-2) 0 0; + padding-inline-start: var(--ar-space-3); + border-inline-start: 2px solid var(--ar-warning); + color: var(--ar-warning); + font-size: var(--ar-font-sm); + line-height: 1.5; +} diff --git a/apps/web/src/app/features/quests/quest-objective-line.component.ts b/apps/web/src/app/features/quests/quest-objective-line.component.ts new file mode 100644 index 0000000..7a529b5 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-objective-line.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; +import { QuestObjectiveView } from '../../core/api/game-api.models'; + +/** + * One objective, exactly as Slice 0.9 §12 prints it: + * + * ```text + * Collect Ashen Pelts 1 / 5 + * + * You cannot carry enough pelts. Return to the South Gate Warden. + * ``` + * + * Shared by the journal and the NPC screen so the two can never word the same + * step differently (AGENTS.md §20). Purely presentational -- it renders what it + * is handed and decides nothing. + */ +@Component({ + selector: 'app-quest-objective-line', + templateUrl: './quest-objective-line.component.html', + styleUrl: './quest-objective-line.component.scss', +}) +export class QuestObjectiveLineComponent { + @Input({ required: true }) objective!: QuestObjectiveView; + /** Shown only when the server says this step cannot progress right now. */ + @Input() hint: string | null = null; + + /** Talk steps have nothing to count, so they show no tally. */ + protected get showsProgress(): boolean { + return this.objective.type === 'COLLECT_ITEM'; + } +} diff --git a/apps/web/src/app/features/quests/quest-page.component.html b/apps/web/src/app/features/quests/quest-page.component.html new file mode 100644 index 0000000..a7a1cc0 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.html @@ -0,0 +1,52 @@ +
+

Quests

+ + @if (store.error(); as error) { + + } + +
+

On the Road

+ + @for (quest of store.activeQuests(); track quest.key) { +
+

{{ quest.title }}

+

{{ quest.description }}

+ @if (currentObjective(quest); as objective) { + + } +
+ } @empty { +

You have taken nothing on.

+ } +
+ + @if (store.availableQuests().length > 0) { +
+

Waiting to be Asked

+ + @for (quest of store.availableQuests(); track quest.key) { +
+

{{ quest.title }}

+

{{ quest.description }}

+
+ } +
+ } + + @if (store.completedQuests().length > 0) { +
+

Behind You

+ + @for (quest of store.completedQuests(); track quest.key) { +
+

{{ quest.title }}

+

Completed.

+
+ } +
+ } +
diff --git a/apps/web/src/app/features/quests/quest-page.component.scss b/apps/web/src/app/features/quests/quest-page.component.scss new file mode 100644 index 0000000..5dee1fa --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.scss @@ -0,0 +1,71 @@ +:host { + display: block; +} + +/* A reading column, not a dashboard grid: the journal is prose with counts + (AGENTS.md §19). */ +.quest-page { + display: flex; + flex-direction: column; + gap: var(--ar-space-5); + max-inline-size: 48rem; +} + +.quest-page__title { + margin: 0; + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1.5rem, 2.6vw, 2rem); + font-weight: 400; + letter-spacing: 0.04em; + text-shadow: 0 0.1rem 0.6rem rgb(0 0 0 / 0.8); +} + +.quest-page__panel { + display: flex; + flex-direction: column; + gap: var(--ar-space-4); +} + +.quest-page__notice { + display: flex; + gap: var(--ar-space-3); + align-items: center; + margin: 0; + color: var(--ar-danger); + font-size: var(--ar-font-sm); +} + +.quest-page__empty { + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + font-style: italic; +} + +/* Separated by a hairline rather than a card each: several quests are one + list, not a stack of tiles. */ +.quest + .quest { + padding-block-start: var(--ar-space-4); + border-block-start: 1px solid rgb(85 74 57 / 0.5); +} + +.quest__title { + margin: 0 0 var(--ar-space-1); + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.1rem; + font-weight: 400; + letter-spacing: 0.03em; +} + +.quest__description { + margin: 0 0 var(--ar-space-3); + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + line-height: 1.6; +} + +.quest--muted .quest__title { + color: var(--ar-text-muted); +} diff --git a/apps/web/src/app/features/quests/quest-page.component.spec.ts b/apps/web/src/app/features/quests/quest-page.component.spec.ts new file mode 100644 index 0000000..11cd47e --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.spec.ts @@ -0,0 +1,139 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import type { QuestView } from '../../core/api/game-api.models'; +import { QuestPageComponent } from './quest-page.component'; +import { QuestStore } from './quest.store'; + +function quest(over: Partial = {}): QuestView { + return { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'The warden wants five Ashen Pelts.', + status: 'ACTIVE', + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: 'COLLECT_ITEM', + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: null, + ...over, + }; +} + +async function setup(quests: QuestView[], error: string | null = null) { + const questsSignal = signal(quests); + const store = { + quests: questsSignal.asReadonly(), + activeQuests: signal(quests.filter((entry) => entry.status === 'ACTIVE')), + completedQuests: signal( + quests.filter((entry) => entry.status === 'COMPLETED'), + ), + availableQuests: signal( + quests.filter((entry) => entry.status === 'AVAILABLE'), + ), + loading: signal(false), + error: signal(error), + load: vi.fn().mockResolvedValue(undefined), + setQuest: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [QuestPageComponent], + providers: [{ provide: QuestStore, useValue: store }], + }).compileComponents(); + + const fixture = TestBed.createComponent(QuestPageComponent); + fixture.detectChanges(); + return { fixture, store, text: () => fixture.nativeElement.textContent ?? '' }; +} + +describe('QuestPageComponent', () => { + it('loads the quest log on init', async () => { + const { store } = await setup([quest()]); + + expect(store.load).toHaveBeenCalledOnce(); + }); + + it('renders the active quest with its current objective', async () => { + const { fixture, text } = await setup([quest()]); + + expect(text()).toContain('Trouble Beyond the Gate'); + expect(text()).toContain('The warden wants five Ashen Pelts.'); + expect( + fixture.nativeElement.querySelector('[data-objective-description]') + .textContent, + ).toContain('Collect Ashen Pelts'); + }); + + it('renders collect progress as current over required', async () => { + const { fixture } = await setup([quest()]); + + // The block slice §12 prints verbatim. + expect( + fixture.nativeElement + .querySelector('[data-objective-progress]') + .textContent.replace(/\s+/g, ' ') + .trim(), + ).toBe('1 / 5'); + }); + + it('renders the blocked hint under the objective', async () => { + const { fixture } = await setup([ + quest({ + hint: 'You cannot carry enough pelts. Return to the South Gate Warden.', + }), + ]); + + expect( + fixture.nativeElement.querySelector('[data-objective-hint]').textContent, + ).toContain('You cannot carry enough pelts.'); + }); + + it('shows no hint when the step is merely unfinished', async () => { + const { fixture } = await setup([quest()]); + + expect( + fixture.nativeElement.querySelector('[data-objective-hint]'), + ).toBeNull(); + }); + + it('shows an empty state when nothing is taken on', async () => { + const { text } = await setup([]); + + expect(text()).toContain('You have taken nothing on.'); + }); + + it('lists completed quests separately from active ones', async () => { + const { fixture, text } = await setup([ + quest({ + key: 'done-quest', + title: 'An Older Errand', + status: 'COMPLETED', + currentObjectiveKey: null, + }), + ]); + + expect(text()).toContain('An Older Errand'); + expect( + fixture.nativeElement.querySelector('[data-quest-completed]'), + ).not.toBeNull(); + // The active panel is still empty, so the two do not blur together. + expect(text()).toContain('You have taken nothing on.'); + }); + + it('offers a retry when the log could not be read', async () => { + const { fixture, store } = await setup([], 'Could not load your quests.'); + + fixture.nativeElement.querySelector('[data-quest-retry]').click(); + + expect(store.load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/features/quests/quest-page.component.ts b/apps/web/src/app/features/quests/quest-page.component.ts new file mode 100644 index 0000000..5828b61 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.ts @@ -0,0 +1,38 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { QuestView } from '../../core/api/game-api.models'; +import { QuestObjectiveLineComponent } from './quest-objective-line.component'; +import { QuestStore } from './quest.store'; + +/** + * The quest journal (Playable Slice 0.9 §12). + * + * Deliberately small: §15 rules out a large journal taxonomy, so this is one + * list in three states -- what you are doing, what is on offer, what is behind + * you -- and nothing else. + */ +@Component({ + selector: 'app-quest-page', + imports: [QuestObjectiveLineComponent], + templateUrl: './quest-page.component.html', + styleUrl: './quest-page.component.scss', +}) +export class QuestPageComponent implements OnInit { + protected readonly store = inject(QuestStore); + + ngOnInit(): void { + void this.store.load(); + } + + /** The step the player is on, or null for a quest that is not started. */ + protected currentObjective(quest: QuestView) { + return ( + quest.objectives.find( + (objective) => objective.key === quest.currentObjectiveKey, + ) ?? null + ); + } + + protected retry(): void { + void this.store.load(); + } +} diff --git a/apps/web/src/app/features/quests/quest.store.spec.ts b/apps/web/src/app/features/quests/quest.store.spec.ts new file mode 100644 index 0000000..c7b7f36 --- /dev/null +++ b/apps/web/src/app/features/quests/quest.store.spec.ts @@ -0,0 +1,108 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import type { QuestView } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { QuestStore } from './quest.store'; + +function quest(over: Partial = {}): QuestView { + return { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + status: 'ACTIVE', + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: 'COLLECT_ITEM', + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: null, + ...over, + }; +} + +function createStore(getQuests: ReturnType): QuestStore { + TestBed.configureTestingModule({ + providers: [{ provide: GameApiService, useValue: { getQuests } }], + }); + return TestBed.inject(QuestStore); +} + +describe('QuestStore', () => { + it('loads the quest log', async () => { + const store = createStore(vi.fn().mockReturnValue(of([quest()]))); + + await store.load(); + + expect(store.quests()).toHaveLength(1); + expect(store.error()).toBeNull(); + expect(store.loading()).toBe(false); + }); + + it('splits the log by status', async () => { + const store = createStore( + vi.fn().mockReturnValue( + of([ + quest({ key: 'active-quest', status: 'ACTIVE' }), + quest({ key: 'done-quest', status: 'COMPLETED' }), + quest({ key: 'offered-quest', status: 'AVAILABLE' }), + ]), + ), + ); + + await store.load(); + + expect(store.activeQuests().map((entry) => entry.key)).toEqual([ + 'active-quest', + ]); + expect(store.completedQuests().map((entry) => entry.key)).toEqual([ + 'done-quest', + ]); + expect(store.availableQuests().map((entry) => entry.key)).toEqual([ + 'offered-quest', + ]); + }); + + it('keeps the previous log when a reload fails', async () => { + const getQuests = vi.fn().mockReturnValue(of([quest()])); + const store = createStore(getQuests); + await store.load(); + + getQuests.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + await store.load(); + + // A stale journal beats a blank one; the next load resyncs. + expect(store.quests()).toHaveLength(1); + expect(store.error()).toBe('Could not load your quests.'); + }); + + it('replaces a quest in place when the NPC screen pushes an update', async () => { + const store = createStore(vi.fn().mockReturnValue(of([quest()]))); + await store.load(); + + store.setQuest(quest({ status: 'COMPLETED', currentObjectiveKey: null })); + + expect(store.quests()).toHaveLength(1); + expect(store.completedQuests()).toHaveLength(1); + }); + + it('adds a quest the log had never seen', async () => { + // Accepting a quest at an NPC before ever opening the journal. + const store = createStore(vi.fn().mockReturnValue(of([]))); + await store.load(); + + store.setQuest(quest()); + + expect(store.activeQuests()).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/features/quests/quest.store.ts b/apps/web/src/app/features/quests/quest.store.ts new file mode 100644 index 0000000..1eba554 --- /dev/null +++ b/apps/web/src/app/features/quests/quest.store.ts @@ -0,0 +1,83 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { QuestView } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; + +const GENERIC_ERROR = 'Could not load your quests.'; + +/** + * The quest log (Playable Slice 0.9 §12). + * + * Holds no derived progress of its own: which step is current, how far along it + * is and whether it is blocked all arrive decided from the server + * (AGENTS.md §22). The store's only job is to keep the newest answer. + */ +@Injectable({ providedIn: 'root' }) +export class QuestStore { + private readonly api = inject(GameApiService); + + private readonly questsState = signal([]); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + + readonly quests = this.questsState.asReadonly(); + readonly loading = this.loadingState.asReadonly(); + readonly error = this.errorState.asReadonly(); + + readonly activeQuests = computed(() => + this.questsState().filter((quest) => quest.status === 'ACTIVE'), + ); + readonly completedQuests = computed(() => + this.questsState().filter((quest) => quest.status === 'COMPLETED'), + ); + readonly availableQuests = computed(() => + this.questsState().filter((quest) => quest.status === 'AVAILABLE'), + ); + + async load(): Promise { + this.loadingState.set(true); + this.errorState.set(null); + + try { + this.questsState.set(await firstValueFrom(this.api.getQuests())); + } catch (error) { + // The previous log stays on screen. A failed refresh is a worse reason to + // blank the journal than it is to show a slightly stale one. + this.errorState.set(this.toMessage(error)); + } finally { + this.loadingState.set(false); + } + } + + /** + * Folds a quest the NPC screen just changed back into the log. + * + * The step endpoint already returns the updated quest, so re-reading the + * whole log for it would be a round trip that answers a question the client + * already has. + */ + setQuest(quest: QuestView): void { + this.questsState.update((current) => { + const index = current.findIndex( + (candidate) => candidate.key === quest.key, + ); + if (index === -1) { + return [...current, quest]; + } + const next = [...current]; + next[index] = quest; + return next; + }); + } + + private toMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + if (code === 'CHARACTER_NOT_FOUND') { + return 'Your character could not be found.'; + } + } + return GENERIC_ERROR; + } +} diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index a40face..ab721d1 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -45,9 +45,12 @@