feat(web): add the quest journal

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:12:09 +02:00
parent 636efb6b5a
commit bc0d910190
12 changed files with 602 additions and 7 deletions

View File

@@ -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: () =>

View File

@@ -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<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
).toBe(true);
}
// Quests became reachable in Slice 0.9; Character is still unbuilt.
const questsButton = element.querySelector<HTMLButtonElement>(
'[data-navigation="quests"]',
);
expect(questsButton?.disabled).toBe(false);
expect(questsButton?.getAttribute('aria-label')).toBe('Quests');
expect(
element.querySelector<HTMLButtonElement>('[data-navigation="character"]')
?.disabled,
).toBe(true);
expect(element.textContent).not.toContain('Shop');
});

View File

@@ -0,0 +1,14 @@
<p class="quest-objective" [class.quest-objective--done]="objective.completed">
<span class="quest-objective__text" data-objective-description>{{ objective.description }}</span>
@if (showsProgress) {
<span
class="quest-objective__progress"
data-objective-progress
[attr.aria-label]="objective.current + ' of ' + objective.required"
>{{ objective.current }} / {{ objective.required }}</span
>
}
</p>
@if (hint) {
<p class="quest-objective__hint" data-objective-hint>{{ hint }}</p>
}

View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -0,0 +1,52 @@
<section class="quest-page" aria-label="Quest Journal">
<h1 class="quest-page__title">Quests</h1>
@if (store.error(); as error) {
<p class="quest-page__notice" role="alert">
{{ error }}
<button type="button" data-quest-retry (click)="retry()">Retry</button>
</p>
}
<section class="ar-panel quest-page__panel" aria-label="Active Quests">
<h2 class="ar-panel__title">On the Road</h2>
@for (quest of store.activeQuests(); track quest.key) {
<article class="quest" [attr.data-quest]="quest.key">
<h3 class="quest__title">{{ quest.title }}</h3>
<p class="quest__description">{{ quest.description }}</p>
@if (currentObjective(quest); as objective) {
<app-quest-objective-line [objective]="objective" [hint]="quest.hint" />
}
</article>
} @empty {
<p class="quest-page__empty" role="status">You have taken nothing on.</p>
}
</section>
@if (store.availableQuests().length > 0) {
<section class="ar-panel quest-page__panel" aria-label="Available Quests">
<h2 class="ar-panel__title">Waiting to be Asked</h2>
@for (quest of store.availableQuests(); track quest.key) {
<article class="quest quest--muted" [attr.data-quest]="quest.key">
<h3 class="quest__title">{{ quest.title }}</h3>
<p class="quest__description">{{ quest.description }}</p>
</article>
}
</section>
}
@if (store.completedQuests().length > 0) {
<section class="ar-panel quest-page__panel" aria-label="Completed Quests">
<h2 class="ar-panel__title">Behind You</h2>
@for (quest of store.completedQuests(); track quest.key) {
<article class="quest quest--muted" [attr.data-quest]="quest.key">
<h3 class="quest__title">{{ quest.title }}</h3>
<p class="quest__description" data-quest-completed>Completed.</p>
</article>
}
</section>
}
</section>

View File

@@ -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);
}

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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();
}
}

View File

@@ -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> = {}): 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<typeof vi.fn>): 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);
});
});

View File

@@ -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<QuestView[]>([]);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(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<void> {
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;
}
}

View File

@@ -45,9 +45,12 @@
<button
class="side-navigation__item"
type="button"
routerLink="/quests"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="quests"
disabled
aria-label="Quests are not yet available"
aria-label="Quests"
>
<img src="/images/hud/runtime/QuestsIcon-128.png" alt="" />
<span>Quests</span>