feat(web): add the quest journal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,13 @@ export const routes: Routes = [
|
|||||||
(module) => module.MerchantPageComponent,
|
(module) => module.MerchantPageComponent,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'quests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/quests/quest-page.component').then(
|
||||||
|
(module) => module.QuestPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'inventory',
|
path: 'inventory',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -67,11 +67,17 @@ describe('App', () => {
|
|||||||
expect(inventoryButton?.disabled).toBe(false);
|
expect(inventoryButton?.disabled).toBe(false);
|
||||||
expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventory');
|
expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventory');
|
||||||
|
|
||||||
for (const destination of ['quests', 'character']) {
|
// 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(
|
expect(
|
||||||
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
element.querySelector<HTMLButtonElement>('[data-navigation="character"]')
|
||||||
|
?.disabled,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
|
||||||
|
|
||||||
expect(element.textContent).not.toContain('Shop');
|
expect(element.textContent).not.toContain('Shop');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/web/src/app/features/quests/quest-page.component.html
Normal file
52
apps/web/src/app/features/quests/quest-page.component.html
Normal 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>
|
||||||
71
apps/web/src/app/features/quests/quest-page.component.scss
Normal file
71
apps/web/src/app/features/quests/quest-page.component.scss
Normal 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);
|
||||||
|
}
|
||||||
139
apps/web/src/app/features/quests/quest-page.component.spec.ts
Normal file
139
apps/web/src/app/features/quests/quest-page.component.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
apps/web/src/app/features/quests/quest-page.component.ts
Normal file
38
apps/web/src/app/features/quests/quest-page.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
108
apps/web/src/app/features/quests/quest.store.spec.ts
Normal file
108
apps/web/src/app/features/quests/quest.store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/web/src/app/features/quests/quest.store.ts
Normal file
83
apps/web/src/app/features/quests/quest.store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,12 @@
|
|||||||
<button
|
<button
|
||||||
class="side-navigation__item"
|
class="side-navigation__item"
|
||||||
type="button"
|
type="button"
|
||||||
|
routerLink="/quests"
|
||||||
|
routerLinkActive="side-navigation__item--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
data-navigation="quests"
|
data-navigation="quests"
|
||||||
disabled
|
aria-label="Quests"
|
||||||
aria-label="Quests are not yet available"
|
|
||||||
>
|
>
|
||||||
<img src="/images/hud/runtime/QuestsIcon-128.png" alt="" />
|
<img src="/images/hud/runtime/QuestsIcon-128.png" alt="" />
|
||||||
<span>Quests</span>
|
<span>Quests</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user