68 KiB
First Quest & Loot-Bag Tutorial — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a minimal step-based quest system and use it for one playable NPC chain that exposes the default 1-item HIDE capacity, refers the player to Borin, grants the Basic Hide Bag, and consumes five Ashen Pelts on turn-in.
Architecture: Four new tables — quest_definitions, quest_objectives (content), npc_quest_assignments (content, NPC spec §14), character_quests (player state). Objectives are an ordered list; the active step is derived by walking forward from a persisted index past COLLECT_ITEM steps that are either satisfied or capacity-blocked, so item progress always reads from currently owned quantity and can never softlock. TALK_TO_NPC steps advance through explicit REST endpoints and carry their own effects as content (set a flag on another NPC's state, grant a loot bag, complete the quest). QuestProgressService lives in its own module so NpcService can derive quest markers without a module cycle.
Tech Stack: NestJS 11, TypeORM (Postgres), Angular (standalone components + signals), Jest (API), Vitest (web).
Spec: docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md
Supporting: docs/Ashen_Realms_NPC_System_Specification_V1.md (§14 quest assignment, §19–21 conditions, §40 implementation status), docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md (§7 bypass conditions), docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md (§6–8 capacity), AGENTS.md.
Global Constraints
- All game-content source text, identifiers, DB names and API contracts are English (AGENTS.md §33, spec §14 "All dialogue/UI text is English").
- Server-authoritative: the client sends intentions only. Quest progress, item ownership, bag grants and rewards are computed server-side (AGENTS.md §5).
- API routes are prefixed
/api; the frontend uses relative URLs only (AGENTS.md §6). - Schema changes go through a TypeORM migration; never
synchronize: true(AGENTS.md §7). - Content rows carry stable human-readable keys and stable UUIDs so re-seeding re-tunes instead of duplicating (AGENTS.md §8). Seeds must stay idempotent.
- Domain errors use stable machine-readable codes in the shape
{ statusCode, code, message }(AGENTS.md §6). - No XP reward (spec §9, §14). No renown milestone for this quest — see Decision D2. Reward silver is 0 — see Decision D3.
- Do not build a branching narrative engine; a step-based state machine is sufficient (spec §10). Out of scope entirely (spec §15): branching choices, voice acting, cinematic dialogue, daily quests, repeatable bounties, quest sharing, party progress, large journal taxonomy.
- Every module that owns entities must declare them in
TypeOrmModule.forFeatureeven when the service resolves repositories offDataSource— the runtime usesautoLoadEntities.
Decisions taken before implementation
These resolve conflicts between the 0.9 spec and already-implemented slices. They were confirmed with the project owner. Record each one in the code comment named in its task.
- D1 — The Basic Hide Bag is granted free by the quest step, not sold. Spec §6 shows Borin saying "Take this" while also describing the 0.8.5 offer (35 Silver,
bypassConditionson the referral flag). A player at that point has ~0 Silver, so the purchase path alone would stall the chain. The quest step therefore grants the bag directly and idempotently, and still setsreferred-by-south-gate-wardenon the character's Borin state, which opens the 0.8.5 offer as a visible consequence and a re-acquisition path. Satisfies spec §5 (referral flag persists), §6 (bypass opens the offer), §9 (bag is the main reward), §13 ("merchant grants Basic Hide Bag once", "bag grant is idempotent"). - D2 — No World Renown milestone for this quest. Spec §9 lists it as optional. The demo character sits at Renown 1 and
first-goods-returnedtakes them to 2; a quest milestone would reach 3 and unlock Borin's Bandit Blade, which Slice 0.8.5 deliberately parked out of reach until Slice 0.11. Regional reputation (+10 Border Watch) is the one-time reward instead. - D3 — Reward Silver is 0. Spec §9: the main reward is the bag and system knowledge, and a Silver reward must not undermine the merchant trade loop. The column exists so this is a content tune, not a code change.
- D4 — Quest steps use dedicated REST endpoints, not dialogue actions. NPC spec §40 asks Slice 0.9 to also wire
START_QUEST/COMPLETE_QUESTdialogue actions, but there is no dialogue-response endpoint at all today and spec §10 forbids a narrative engine. Steps are triggered byPOST /api/npcs/:npcKey/quests/:questKey/{accept,advance}; step text is content on the objective. Task 12 records this deviation in the NPC spec. - D5 — The seeded free Hide Bag for the demo character is removed.
vertical-slice.seed.tsalready carries the note "Delete this block once 0.9 hands the Hide Bag over in the quest." Without removing it the slice's whole premise (default capacity 1) is invisible. The migration additionally deletes that one row forDEMO_CHARACTER_IDon existing dev databases. Narrow and documented; the only other way to hold that bag today is a Reputation-40 purchase.
File Structure
API — new files
| File | Responsibility |
|---|---|
apps/api/src/quests/quest.types.ts |
QuestObjectiveType, NpcQuestRole, CharacterQuestStatus, all DTO interfaces |
apps/api/src/quests/entities/quest-definition.entity.ts |
Quest content row |
apps/api/src/quests/entities/quest-objective.entity.ts |
One ordered step + its effects |
apps/api/src/quests/entities/npc-quest-assignment.entity.ts |
NPC ↔ quest role link (NPC spec §14) |
apps/api/src/quests/entities/character-quest.entity.ts |
Player state |
apps/api/src/quests/quest-state.ts |
Pure derivation: progress → effective objective index |
apps/api/src/quests/quest-progress.service.ts |
Loads quest state for a character (no NpcService dependency) |
apps/api/src/quests/quest-progress.module.ts |
Provides/exports QuestProgressService — breaks the NPC↔quest module cycle |
apps/api/src/quests/quest.service.ts |
Accept / advance, effects, rewards, transactions |
apps/api/src/quests/quest.errors.ts |
Domain errors |
apps/api/src/quests/quest.controller.ts |
GET /api/quests, accept/advance under /api/npcs/:npcKey |
apps/api/src/quests/quests.module.ts |
Wires the above |
apps/api/src/database/migrations/1797000000000-CreateQuestSystem.ts |
Schema |
apps/api/src/database/seeds/quest-content.ts |
The quest, its objectives, assignments, warden ids |
API — modified
| File | Change |
|---|---|
apps/api/src/conditions/game-condition.types.ts |
QUEST_ACTIVE / QUEST_COMPLETED join SUPPORTED_CONDITION_TYPES |
apps/api/src/conditions/game-condition.service.ts |
Evaluate both, boolean-style like FLAG_SET |
apps/api/src/conditions/conditions.module.ts |
Register quest entities |
apps/api/src/npcs/npc.types.ts |
NpcMarker gains QUEST_IN_PROGRESS; refresh the stale Slice-0.9 comments |
apps/api/src/npcs/npc.service.ts |
Quest markers; getNpcsAtLocation takes characterId |
apps/api/src/npcs/npc.controller.ts |
Pass DEMO_CHARACTER_ID to getNpcsAtLocation |
apps/api/src/npcs/npcs.module.ts |
Import QuestProgressModule |
apps/api/src/database/seeds/npc-content.ts |
Warden NPC + dialogue nodes |
apps/api/src/database/seeds/local-location.content.ts |
gate-watch POI becomes the warden's doorway |
apps/api/src/database/seeds/vertical-slice.seed.ts |
Seed quest content; drop the free Hide Bag block (D5) |
apps/api/src/app.module.ts |
Import QuestsModule |
Web — new files
| File | Responsibility |
|---|---|
apps/web/src/app/features/quests/quest.store.ts |
Quest log state |
apps/web/src/app/features/quests/quest-page.component.{ts,html,scss} |
/quests journal |
apps/web/src/app/features/quests/quest-objective-line.component.{ts,html,scss} |
Shared "objective + progress + hint" block, reused by the NPC panel |
Web — modified
| File | Change |
|---|---|
apps/web/src/app/core/api/game-api.models.ts |
Quest models; NpcMarker gains QUEST_IN_PROGRESS |
apps/web/src/app/core/api/game-api.service.ts |
getQuests, acceptQuest, advanceQuest |
apps/web/src/app/app.routes.ts |
/quests route |
apps/web/src/app/layout/side-navigation/side-navigation.component.html |
Enable the Quests item |
apps/web/src/app/features/npc/merchant.store.ts |
Quest state + accept/advance + granted-bag notice |
apps/web/src/app/features/npc/merchant-page.component.{ts,html,scss} |
Quest panel |
apps/web/src/app/features/world/world.store.ts |
Load NPC markers for the current location |
apps/web/src/app/features/world/location-page/location-page.component.{ts,html} |
Pass markers to hotspots |
apps/web/src/app/features/world/location-poi/location-poi.component.{ts,html,scss} |
Render the marker badge |
Assets
| File | Source |
|---|---|
apps/web/public/images/npcs/south-gate-warden.png |
art/npc/Graufurt_Offizier.png |
apps/web/public/images/npcs/borin.png |
art/npc/Graufurt_Haendler.png — the path is already referenced by seeded content and currently 404s; the quest chain routes the player through Borin's screen, so it is fixed here |
Content constants (single source of truth — Task 2 defines them, later tasks import them)
Quest key trouble-beyond-the-gate
Quest id c0000000-0000-4000-8000-000000000001
Objective ids c1000000-0000-4000-8000-00000000000{1..5}
Assignment ids c2000000-0000-4000-8000-00000000000{1..3}
Warden NPC id b0000000-0000-4000-8000-000000000002
Warden NPC key south-gate-warden
Referral flag referred-by-south-gate-warden (already exported as SOUTH_GATE_REFERRAL_FLAG)
Reward faction border-guard, +10 reputation, 0 silver, no renown milestone
Task 1: Quest schema — entities and migration
Files:
- Create:
apps/api/src/quests/quest.types.ts - Create:
apps/api/src/quests/entities/quest-definition.entity.ts - Create:
apps/api/src/quests/entities/quest-objective.entity.ts - Create:
apps/api/src/quests/entities/npc-quest-assignment.entity.ts - Create:
apps/api/src/quests/entities/character-quest.entity.ts - Create:
apps/api/src/database/migrations/1797000000000-CreateQuestSystem.ts - Test:
apps/api/src/database/migrations/create-quest-system.migration.spec.ts
Interfaces:
- Produces:
enum QuestObjectiveType { COLLECT_ITEM = 'COLLECT_ITEM', TALK_TO_NPC = 'TALK_TO_NPC' }enum NpcQuestRole { OFFER = 'OFFER', TURN_IN = 'TURN_IN', PROGRESS = 'PROGRESS' }enum CharacterQuestStatus { ACTIVE = 'ACTIVE', COMPLETED = 'COMPLETED' }class QuestDefinition { id, key, title, description, rewardFactionKey: string | null, rewardReputation: number, rewardSilver: number, enabled, createdAt, updatedAt }class QuestObjective { id, questId, key, orderIndex: number, type: QuestObjectiveType, targetKey: string, requiredQuantity: number, description: string, npcLine: string | null, hintText: string | null, advanceWhenBlocked: boolean, consumeOnComplete: boolean, grantsLootBagKey: string | null, setsFlagKey: string | null, setsFlagNpcKey: string | null, enabled, createdAt, updatedAt }class NpcQuestAssignment { id, npcId, questId, role: NpcQuestRole, enabled, createdAt, updatedAt }class CharacterQuest { id, characterId, questId, status: CharacterQuestStatus, currentObjectiveIndex: number, acceptedAt: Date, completedAt: Date | null, createdAt, updatedAt }
Column-name mapping is snake_case throughout (order_index, advance_when_blocked, grants_loot_bag_key, sets_flag_key, sets_flag_npc_key, consume_on_complete, current_objective_index, …), matching every existing entity in the repo.
Follow the established entity conventions exactly: @PrimaryGeneratedColumn('uuid', { name: 'id' }), @CreateDateColumn/@UpdateDateColumn with type: 'timestamptz', enum columns with an explicit enumName, @ManyToOne + @JoinColumn relations, and a doc comment on each entity explaining why it is shaped that way (see shop-offer.entity.ts for the tone).
Key comments to write:
QuestObjective: the effect columns are content, not code — a step declares what it does (setsFlagKeyonsetsFlagNpcKey's state,grantsLootBagKey,consumeOnComplete) so the service stays a state machine rather than a switch on quest keys (AGENTS.md §9).QuestObjective.advanceWhenBlocked: this is the whole capacity lesson (spec §4). A collect step with this flag also counts as done when the character physically cannot carry more of the target's loot category, which is what routes the player back to the warden instead of leaving them stuck at 1/5.QuestObjective.setsFlagNpcKey: dialogue flags are per-NPC state (NPC spec §7), so the warden's referral has to be written onto the character's Borin row for the 0.8.5bypassConditionsgate to see it (spec §6).CharacterQuest.currentObjectiveIndex: the floor, not the answer. Collect steps are re-derived from owned quantity on every read (spec §11), so this index only ever moves when a TALK step is performed.
Indexes/constraints the migration must create:
IDX_quest_definitions_keyunique onkeyIDX_quest_objectives_quest_keyunique on (quest_id,key)IDX_quest_objectives_quest_orderunique on (quest_id,order_index)IDX_npc_quest_assignments_npc_quest_roleunique on (npc_id,quest_id,role)IDX_npc_quest_assignments_npcon (npc_id)IDX_character_quests_character_questunique on (character_id,quest_id) — this is what makes "accepted only once" a database guarantee, not a UI one (AGENTS.md §30)- FKs: objectives → quests
ON DELETE CASCADE; assignments → npcs and questsON DELETE CASCADE; character_quests → charactersON DELETE CASCADE, → questsON DELETE RESTRICT CHK_quest_objectives_required_quantityCHECKrequired_quantity >= 1CHK_character_quests_objective_indexCHECKcurrent_objective_index >= 0
The migration also creates the three Postgres enum types quest_objective_type_enum, npc_quest_role_enum, character_quest_status_enum, and — per D5 — ends up() with:
-- Slice 0.9 D5: the vertical-slice seed handed the demo character a free Basic
-- Hide Bag as a stopgap and said in a comment to remove it once 0.9 grants the
-- bag through the quest. Leaving it in place would hide this slice's entire
-- premise: the default HIDE capacity of 1 is what sends the player to Borin.
-- Scoped to the demo character and that one bag; nothing else is touched.
DELETE FROM "character_loot_bags"
WHERE "character_id" = '10000000-0000-4000-8000-000000000001'
AND "loot_bag_definition_id" = 'a0000000-0000-4000-8000-000000000001'
down() drops the four tables and the three enum types in reverse dependency order. It does not re-insert the deleted bag row — a down-migration restoring player state it cannot distinguish from a real purchase would be worse than the gap. Say so in a comment.
- Step 1: Write the failing migration spec
Model it on apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts (mock QueryRunner, collect the SQL strings, assert on the joined text). Cases:
it('creates the four quest tables', ...) // quest_definitions, quest_objectives, npc_quest_assignments, character_quests
it('makes a quest acceptable only once per character', ...) // unique (character_id, quest_id)
it('keeps objective order unique within a quest', ...) // unique (quest_id, order_index)
it('lets one quest use several NPCs in different roles', ...) // unique triple includes role
it('stores step effects as content columns', ...) // grants_loot_bag_key, sets_flag_key, sets_flag_npc_key, advance_when_blocked, consume_on_complete
it('removes the demo character stopgap hide bag', ...) // DELETE FROM "character_loot_bags"
it('drops everything it created on down', ...)
- Step 2: Run the spec and confirm it fails
Run: npm test --workspace=@ashen-realms/api -- create-quest-system
Expected: FAIL — cannot resolve ./1797000000000-CreateQuestSystem.
-
Step 3: Write
quest.types.tsand the four entities -
Step 4: Write the migration
-
Step 5: Add an entity-metadata spec
Create apps/api/src/quests/entities/quest-entities.metadata.spec.ts modelled on renown-and-reputation-entities.metadata.spec.ts: assert via getMetadataArgsStorage() that each entity maps to the expected table name and that the enum columns declare the expected enumName. This is what catches an entity/migration drift that the SQL-string spec cannot see.
- Step 6: Run both specs
Run: npm test --workspace=@ashen-realms/api -- quest
Expected: PASS.
- Step 7: Commit
git add apps/api/src/quests apps/api/src/database/migrations
git commit -m "feat(quests): add quest schema, entities and migration"
Task 2: Quest content, the South Gate Warden, and seed wiring
Files:
- Create:
apps/api/src/database/seeds/quest-content.ts - Modify:
apps/api/src/database/seeds/npc-content.ts(warden NPC + dialogue nodes) - Modify:
apps/api/src/database/seeds/local-location.content.ts(gate-watchPOI → warden doorway) - Modify:
apps/api/src/database/seeds/vertical-slice.seed.ts(seed quest content; remove the free Hide Bag block) - Create:
apps/web/public/images/npcs/south-gate-warden.png,apps/web/public/images/npcs/borin.png - Test:
apps/api/src/database/seeds/vertical-slice.seed.spec.ts(extend)
Interfaces:
- Consumes: entities and enums from Task 1;
SOUTH_GATE_REFERRAL_FLAG,BORIN_KEY,BORIN_NPC_IDfromnpc-content.ts;ITEM_IDS/'ash-pelt';'basic-hide-bag'fromloot-bag-content.ts. - Produces (from
quest-content.ts):TROUBLE_BEYOND_THE_GATE_QUEST_ID,TROUBLE_BEYOND_THE_GATE_KEY = 'trouble-beyond-the-gate'QUEST_OBJECTIVE_IDS: { collectPeltsFirst, reportCapacity, collectBag, collectPelts, turnIn }interface SeedQuestDefinition,interface SeedQuestObjective,interface SeedNpcQuestAssignmentQUEST_DEFINITIONS: SeedQuestDefinition[],QUEST_OBJECTIVES: SeedQuestObjective[],NPC_QUEST_ASSIGNMENTS: SeedNpcQuestAssignment[]
- Produces (from
npc-content.ts):SOUTH_GATE_WARDEN_NPC_ID,SOUTH_GATE_WARDEN_KEY = 'south-gate-warden'
The quest (spec §2, §3, §5, §7, §8):
key trouble-beyond-the-gate
title Trouble Beyond the Gate
description The South Gate Warden wants to know what the ash is doing to the
creatures beyond the wall, and five Ashen Pelts is how you show them.
rewards border-guard +10 reputation, 0 silver, no renown milestone (D2, D3)
Objectives, in order:
| # | key | type | target | qty | description | effects |
|---|---|---|---|---|---|---|
| 0 | collect-pelts-first |
COLLECT_ITEM | ash-pelt |
5 | Collect Ashen Pelts |
advanceWhenBlocked: true, hintText: 'You cannot carry enough pelts. Return to the South Gate Warden.' |
| 1 | report-capacity |
TALK_TO_NPC | south-gate-warden |
1 | Return to the South Gate Warden |
setsFlagKey: SOUTH_GATE_REFERRAL_FLAG, setsFlagNpcKey: BORIN_KEY, npcLine = spec §5 quote |
| 2 | collect-bag |
TALK_TO_NPC | borin-quartermaster |
1 | Speak with Borin in Graufurt |
grantsLootBagKey: 'basic-hide-bag', npcLine = spec §6 second quote |
| 3 | collect-pelts |
COLLECT_ITEM | ash-pelt |
5 | Collect Ashen Pelts |
advanceWhenBlocked: false, consumeOnComplete: true |
| 4 | turn-in |
TALK_TO_NPC | south-gate-warden |
1 | Bring the pelts to the South Gate Warden |
npcLine = spec §8 quote |
Exact player-facing strings — copy verbatim from the spec:
§5 "Right. You're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful."
§6 "But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble."
§8 "Good. That's enough for me. From now on, take hides and trophies to Borin. He'll pay for useful spoils, and word gets around when you keep the roads clear."
Write a file-level comment explaining why there are two collect steps rather than one with a mid-step hint: the return-to-warden beat is a real step the player performs, so it has to be its own objective; advanceWhenBlocked on the first one is what hands the player over to it at 1/5 instead of stranding them (spec §4, §13 "return step activates correctly"). Only the second collect step consumes (spec §8) — consuming both would demand ten pelts.
Assignments: warden OFFER, warden TURN_IN, Borin PROGRESS. Comment that this is NPC spec §14's model finally in use, and that it is what lets one quest span two NPCs.
The warden NPC (added to NPC_DEFINITIONS in npc-content.ts):
id b0000000-0000-4000-8000-000000000002
key south-gate-warden
name Halvik
title Warden of the South Gate
locationKey south-gate
factionKey border-guard
portraitPath /images/npcs/south-gate-warden.png
capabilities DIALOGUE, QUEST_GIVER, QUEST_TURN_IN
Spec §2 says to reuse an existing named gate NPC — none exists; the current gate-watch hotspot is authored scenery with result text, not an NpcDefinition. Note that in a comment, and note that the NPC is given a name because the spec's own instruction ("Do not create a duplicate NPC merely because this document uses a generic title") assumes named NPCs.
Warden dialogue nodes (added to DIALOGUE_NODES), all gated on the new quest conditions from Task 5:
| key | priority | conditions | text |
|---|---|---|---|
warden-quest-offer |
900 | QUEST_ACTIVE(trouble-beyond-the-gate) = false AND QUEST_COMPLETED(...) = false |
"The road has gone bad. Start small. Bring me five Ashen Pelts from the rats beyond the gate. I want to know what the ash is doing to them." (spec §3, verbatim) |
warden-quest-done |
700 | QUEST_COMPLETED(...) = true |
"The road is no safer, but at least someone is walking it. Whatever you drag back, take it to Borin." |
warden-quest-active |
500 | QUEST_ACTIVE(...) = true |
"Still out there, then. Five pelts, and no fewer." |
warden-default |
100 | none | "Beyond the gate, Graufurt's protection ends. Whoever heads south does so at their own risk." |
POI change in local-location.content.ts: the gate-watch hotspot keeps its coordinates but becomes the warden's doorway — title: 'Halvik, Warden of the South Gate', actionLabel: 'Talk', npcKey: 'south-gate-warden', and resultTitle/resultText removed (LocationPointOfInterestContent allows result text or an npcKey, never both). The talk-to-watch primary action gains the same npcKey and its label becomes Talk to the warden.
Seed wiring in vertical-slice.seed.ts:
- Upsert
QUEST_DEFINITIONSon['key'],QUEST_OBJECTIVESon['id'](an objective has no natural key across quests and itskeyis only unique per quest),NPC_QUEST_ASSIGNMENTSon['id']. Order: afternpcDefinitionRepository.upsert, because assignments reference NPC ids. - Delete the
characterLootBagRepositoryblock that grants the Basic Hide Bag (D5) together with its now-obsolete ASSUMPTION comment. Remove theBASIC_HIDE_BAG_IDandCharacterLootBagimports if nothing else uses them.
Assets: copy art/npc/Graufurt_Offizier.png → apps/web/public/images/npcs/south-gate-warden.png and art/npc/Graufurt_Haendler.png → apps/web/public/images/npcs/borin.png (creating the npcs directory).
- Step 1: Write the failing seed-spec cases
Extend vertical-slice.seed.spec.ts (it already has an InMemoryRepository harness):
it('seeds the South Gate Warden at the south gate', ...)
it('seeds one quest with five ordered objectives', ...)
it('routes the first collect step to the warden when the bag is missing', ...) // advanceWhenBlocked === true on order 0, false on order 3
it('writes the referral flag onto Borin, not the warden', ...) // setsFlagNpcKey === BORIN_KEY
it('consumes pelts only on the second collect step', ...) // consumeOnComplete true only on order 3
it('grants no renown milestone and no silver for the quest', ...) // D2/D3
it('assigns the quest to the warden for offer and turn-in and to Borin for progress', ...)
it('no longer hands the demo character a free hide bag', ...) // D5
it('turns the gate watch hotspot into the warden doorway', ...) // npcKey set, resultText gone
it('leaves a re-seed at five objectives and one assignment set', ...) // idempotency, mirrors the existing re-seed test
- Step 2: Run and confirm they fail
Run: npm test --workspace=@ashen-realms/api -- vertical-slice.seed
Expected: FAIL — quest-content does not exist.
-
Step 3: Write
quest-content.ts, extendnpc-content.tsandlocal-location.content.ts -
Step 4: Wire the seed and remove the free hide bag
-
Step 5: Copy the two portrait assets
-
Step 6: Run the seed specs
Run: npm test --workspace=@ashen-realms/api -- seed
Expected: PASS.
- Step 7: Commit
git add apps/api/src/database/seeds apps/web/public/images/npcs
git commit -m "feat(quests): seed Trouble Beyond the Gate and the South Gate Warden"
Task 3: Pure objective-state derivation
Files:
- Create:
apps/api/src/quests/quest-state.ts - Test:
apps/api/src/quests/quest-state.spec.ts
This is the heart of spec §11 ("Define deterministic behavior") and it is deliberately pure — no database, no Nest — so every softlock case in the spec is a table-driven unit test.
Interfaces:
- Produces:
/** One objective reduced to what the derivation actually needs. */
export interface ObjectiveSnapshot {
type: QuestObjectiveType;
requiredQuantity: number;
advanceWhenBlocked: boolean;
/** Owned quantity for a COLLECT_ITEM step; 0 for a TALK_TO_NPC step. */
current: number;
/** True when the character cannot carry more of this step's target. */
blocked: boolean;
}
/** True when this step needs nothing further from the player. */
export function isObjectiveSatisfied(objective: ObjectiveSnapshot): boolean;
/**
* The step the player is actually on.
*
* Walks forward from the persisted floor past every COLLECT_ITEM step that is
* either satisfied or capacity-blocked. TALK_TO_NPC steps always stop the walk:
* they are performed, not observed. Returns `objectives.length` when every step
* is behind the player, which the caller reads as "ready to complete".
*/
export function resolveCurrentObjectiveIndex(
objectives: ObjectiveSnapshot[],
storedIndex: number,
): number;
isObjectiveSatisfied returns current >= requiredQuantity for a collect step, || (advanceWhenBlocked && blocked) on top of it, and false for a talk step. resolveCurrentObjectiveIndex clamps storedIndex into [0, objectives.length] first — a stored index beyond the list (content shortened between deploys) must read as "done", never crash.
- Step 1: Write the failing spec
const collect = (over: Partial<ObjectiveSnapshot> = {}): ObjectiveSnapshot => ({
type: QuestObjectiveType.COLLECT_ITEM,
requiredQuantity: 5,
advanceWhenBlocked: false,
current: 0,
blocked: false,
...over,
});
const talk = (): ObjectiveSnapshot => ({
type: QuestObjectiveType.TALK_TO_NPC,
requiredQuantity: 1,
advanceWhenBlocked: false,
current: 0,
blocked: false,
});
Cases:
it('stays on an unmet collect step', ...) // [collect()] , 0 -> 0
it('walks past a collect step whose items are already owned', ...) // current 5 -> 1
it('walks past a blocked collect step only when content allows it', ...)
// collect({ current: 1, blocked: true, advanceWhenBlocked: true }) -> 1
// collect({ current: 1, blocked: true, advanceWhenBlocked: false }) -> 0
it('never walks past a talk step', ...) // [talk(), talk()], 0 -> 0
it('walks back when the player sold the items again', ...) // stored 0, current 0 -> 0 after previously reading 1
it('never walks behind the stored floor', ...) // stored 2 with everything unmet -> 2
it('clamps a stored index past the end of the list', ...) // stored 9 of 5 -> 5
it('clamps a negative stored index', ...) // stored -1 -> 0
it('reports the full chain of this slice', ...) // the real five-step shape, walked through each state
The last case is worth writing out in full: build the five real objectives and assert the derived index for each of these states — no pelts/no bag → 0; one pelt/no bag → 1; after the warden talk (stored 2) → 2; after Borin (stored 3) with one pelt → 3; five pelts → 4; after turn-in (stored 5) → 5.
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/api -- quest-state
Expected: FAIL — module not found.
-
Step 3: Implement
quest-state.ts -
Step 4: Run the spec
Run: npm test --workspace=@ashen-realms/api -- quest-state
Expected: PASS.
- Step 5: Commit
git add apps/api/src/quests/quest-state.ts apps/api/src/quests/quest-state.spec.ts
git commit -m "feat(quests): derive the active objective from owned quantity"
Task 4: QuestProgressService and QuestProgressModule
Files:
- Create:
apps/api/src/quests/quest-progress.service.ts - Create:
apps/api/src/quests/quest-progress.module.ts - Test:
apps/api/src/quests/quest-progress.service.spec.ts
Interfaces:
- Consumes: entities (Task 1),
resolveCurrentObjectiveIndex/ObjectiveSnapshot(Task 3),LootCapacityService.getCapacities(characterId, scope?)(existing). - Produces:
export interface QuestObjectiveState {
objective: QuestObjective;
current: number;
required: number;
blocked: boolean;
satisfied: boolean;
}
export interface QuestState {
quest: QuestDefinition;
objectives: QuestObjectiveState[];
row: CharacterQuest | null;
status: 'AVAILABLE' | 'ACTIVE' | 'COMPLETED';
/** Index into `objectives`; null unless ACTIVE. `objectives.length` means every step is behind the player. */
currentIndex: number | null;
}
@Injectable()
export class QuestProgressService {
async getQuestStates(characterId: string, scope?: RepositoryScope): Promise<QuestState[]>;
async getQuestState(characterId: string, questKey: string, scope?: RepositoryScope): Promise<QuestState | null>;
/** Quest states for the quests a given NPC is assigned to, with the roles. */
async getNpcQuestStates(
characterId: string,
npcId: string,
scope?: RepositoryScope,
): Promise<Array<{ state: QuestState; roles: NpcQuestRole[] }>>;
}
RepositoryScope is the repo-wide Pick<DataSource, 'getRepository'> alias so callers can pass a transaction's EntityManager (copy the alias and its comment from loot-capacity.service.ts).
How each piece is computed:
- Owned quantity for a
COLLECT_ITEMstep:ItemDefinitionbytargetKey, thenCharacterItem.quantityfor that definition; a missing row reads as 0. blocked: the target item'slootCategory; null category → never blocked (equipment and consumables are unaffected by bags, Slice 0.7.5 §8). Otherwise blocked when the matchingLootCapacityServiceentry hascurrent >= capacityand the step is not already satisfied.status: noCharacterQuestrow →AVAILABLE; row status maps straight through.currentIndex:resolveCurrentObjectiveIndex(snapshots, row.currentObjectiveIndex)when ACTIVE, else null.- Only
enabledquests andenabledobjectives are loaded; objectives are ordered byorderIndex ASC.
Load capacities once per call and reuse across objectives — comment that, since it is the reason the method takes a whole character rather than one objective.
- Step 1: Write the failing spec
Model the fake DataSource on apps/api/src/shops/shop.service.spec.ts — a getRepository switch returning per-entity fakes, and a hand-written LootCapacityService stub returning fixed LootCapacityDto[].
it('reports an unstarted quest as available with zero progress', ...)
it('counts pelts the character already owned before accepting', ...) // spec §11 first bullet
it('marks a collect step blocked when the hide category is full', ...)
it('does not mark a step blocked once it is already satisfied', ...)
it('never blocks a step whose item has no loot category', ...)
it('derives the active step from the stored floor and current items', ...)
it('reports a completed quest without a current step', ...)
it('ignores disabled quests and disabled objectives', ...)
it('returns the roles an NPC holds for a quest', ...) // getNpcQuestStates
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/api -- quest-progress
Expected: FAIL — module not found.
- Step 3: Implement the service and its module
QuestProgressModule imports TypeOrmModule.forFeature([QuestDefinition, QuestObjective, NpcQuestAssignment, CharacterQuest, CharacterItem, ItemDefinition]) plus LootBagsModule, provides and exports QuestProgressService. Its doc comment must state plainly why it is its own module: NpcService needs quest markers and QuestService needs NpcService, so the read-only half is split out to keep the dependency graph acyclic.
- Step 4: Run the spec
Run: npm test --workspace=@ashen-realms/api -- quest-progress
Expected: PASS.
- Step 5: Commit
git add apps/api/src/quests
git commit -m "feat(quests): read quest progress from owned items and bag capacity"
Task 5: QUEST_ACTIVE and QUEST_COMPLETED conditions
Files:
- Modify:
apps/api/src/conditions/game-condition.types.ts - Modify:
apps/api/src/conditions/game-condition.service.ts - Modify:
apps/api/src/conditions/conditions.module.ts - Test:
apps/api/src/conditions/game-condition.service.spec.ts(extend)
Interfaces:
- Consumes:
QuestDefinition,CharacterQuest,CharacterQuestStatus(Task 1). - Produces:
SUPPORTED_CONDITION_TYPESadditionally containsQUEST_ACTIVEandQUEST_COMPLETED.
Both evaluate like FLAG_SET — a boolean with an expected value, not a numeric comparison:
private async evaluateQuestStatus(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
status: CharacterQuestStatus,
): Promise<ConditionEvaluation> {
if (!condition.key) {
return { met: false, actual: null };
}
const quest = await scope
.getRepository(QuestDefinition)
.findOneBy({ key: condition.key, enabled: true });
if (!quest) {
return { met: false, actual: null };
}
const row = await scope
.getRepository(CharacterQuest)
.findOneBy({ characterId: context.characterId, questId: quest.id });
// `value: false` is how content asks for the negative -- "this quest is not
// yet active" -- without a NOT operator in the condition vocabulary. Same
// shape FLAG_SET already uses.
const expected = condition.value ?? true;
return { met: (row?.status === status) === expected, actual: null };
}
actual stays null: a quest is active or it is not, and reporting a number would be a lie about a boolean (same reasoning already written for FLAG_SET). This matters for the shop view, which renders describe() output as player-facing requirements.
Register QuestDefinition and CharacterQuest in ConditionsModule's forFeature.
Update the SUPPORTED_CONDITION_TYPES doc comment: quests now exist, so only BOSS_DEFEATED (Slice 0.11) and LOCATION_DISCOVERED remain unbacked.
- Step 1: Write the failing spec cases
it('treats an active quest as QUEST_ACTIVE', ...)
it('treats a completed quest as QUEST_COMPLETED, not QUEST_ACTIVE', ...)
it('lets content ask for the negative with value false', ...)
it('fails closed for an unknown quest key', ...)
it('fails closed when the condition names no quest', ...)
it('reports no measured value for a quest condition', ...) // describe() -> actual null
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/api -- game-condition
Expected: FAIL — both types still fail closed via SUPPORTED_CONDITION_TYPES.
-
Step 3: Implement
-
Step 4: Run the conditions and shop specs
Run: npm test --workspace=@ashen-realms/api -- "game-condition|shop"
Expected: PASS — the shop suite must stay green, since it shares the engine.
- Step 5: Commit
git add apps/api/src/conditions
git commit -m "feat(conditions): evaluate QUEST_ACTIVE and QUEST_COMPLETED"
Task 6: QuestService — accept, advance, effects, rewards
Files:
- Create:
apps/api/src/quests/quest.service.ts - Create:
apps/api/src/quests/quest.errors.ts - Test:
apps/api/src/quests/quest.service.spec.ts
Interfaces:
- Consumes:
QuestProgressService(Task 4),NpcService.requireReachableNpc(characterId, npcKey)(existing),ReputationService.grantReputation(characterId, factionKey, amount, manager?)(existing), entities from Task 1. - Produces:
export interface QuestObjectiveDto {
key: string;
description: string;
type: QuestObjectiveType;
targetKey: string;
required: number;
current: number;
completed: boolean;
}
export interface QuestDto {
key: string;
title: string;
description: string;
status: 'AVAILABLE' | 'ACTIVE' | 'COMPLETED';
objectives: QuestObjectiveDto[];
currentObjectiveKey: string | null;
/** Set when the active step cannot progress right now (spec §4, §12). */
hint: string | null;
}
export interface GrantedLootBagDto {
key: string;
name: string;
lootCategory: LootCategory;
capacity: number;
}
export interface QuestInteractionResultDto {
quest: QuestDto;
/** What the NPC says for the step just performed (spec §5, §6, §8). */
npcLine: string | null;
/** Non-null only on the step that hands the bag over (spec §12 Bag UI). */
grantedBag: GrantedLootBagDto | null;
consumedItems: Array<{ itemKey: string; quantity: number }>;
rewards: { factionKey: string | null; reputation: number; silver: number } | null;
}
@Injectable()
export class QuestService {
async getQuestLog(characterId: string): Promise<QuestDto[]>;
async acceptQuest(characterId: string, npcKey: string, questKey: string): Promise<QuestInteractionResultDto>;
async advanceQuest(characterId: string, npcKey: string, questKey: string): Promise<QuestInteractionResultDto>;
}
Error codes in quest.errors.ts, following npc.errors.ts exactly (an HttpException subclass plus one factory per code):
| code | status | message |
|---|---|---|
QUEST_NOT_FOUND |
404 | This quest could not be found. |
QUEST_NOT_OFFERED_HERE |
409 | This person has nothing to ask of you. |
QUEST_ALREADY_ACCEPTED |
409 | You have already taken this on. |
QUEST_NOT_ACTIVE |
409 | You are not on this quest. |
QUEST_STEP_NOT_HERE |
409 | This is not what the quest needs from you right now. |
QUEST_OBJECTIVE_INCOMPLETE |
409 | You do not have what this step needs yet. |
acceptQuest:
requireReachableNpc— the character must be standing with the NPC (never trust the request for location).- Load the quest by key; enabled only, else
QUEST_NOT_FOUND. - Require an enabled
NpcQuestAssignment(npcId, questId, role: OFFER), elseQUEST_NOT_OFFERED_HERE. - In a transaction: insert
CharacterQuest { status: ACTIVE, currentObjectiveIndex: 0, acceptedAt: now }. A pre-existing row →QUEST_ALREADY_ACCEPTED. Catch the unique-violation as well and map it to the same error, so a double-submit loses the race instead of 500ing (AGENTS.md §30). - Return the fresh
QuestDtowithnpcLine: null— the offer line is dialogue content and the NPC screen re-reads it (see D4 note).
advanceQuest:
requireReachableNpc, load quest, elseQUEST_NOT_FOUND.- Whole body inside
dataSource.transaction, taking a pessimistic write lock on theCharacterQuestrow first, so two clicks cannot both consume pelts (AGENTS.md §29, §30). - Status must be
ACTIVE, elseQUEST_NOT_ACTIVE. - Derive the state through
QuestProgressServiceusing the transaction manager. The current objective must beTALK_TO_NPCwithtargetKey === npc.key, elseQUEST_STEP_NOT_HERE. This is also what rejects a turn-in attempt while a collect step is still short — the derived index simply is not the turn-in step. - Persist the floor:
row.currentObjectiveIndex = currentIndex + 1. - Apply the step's effects in this order:
setsFlagKey/setsFlagNpcKey— resolve the target NPC by key, upsert itsCharacterNpcState, merge{ [setsFlagKey]: true }intoflags. Merging, never replacing:metand any other flag on that row must survive (spec §11 "referral flag persists").grantsLootBagKey— resolve theLootBagDefinitionby key; if the character already holds it, skip silently and returngrantedBag: null; otherwise insertCharacterLootBag { active: true }and return the bag. Idempotent by construction (spec §11, §13, D1).- Completion — when
currentIndex + 1 >= objectives.length: consume, reward, and setstatus: COMPLETED,completedAt: now.
- Consumption walks every objective with
consumeOnComplete, locking eachCharacterItemrow and requiringquantity >= requiredQuantity, elseQUEST_OBJECTIVE_INCOMPLETE. Delete the row when the quantity hits exactly zero, otherwise decrement — mirroringExchangeService.consumeItems, which is the existing precedent for this shape. - Rewards:
rewardReputation > 0→ReputationService.grantReputation(characterId, quest.rewardFactionKey, quest.rewardReputation, manager);rewardSilver > 0→ add to the locked character row. Both are content-driven; with the seeded values only reputation fires (D2, D3). Comment that the silver branch exists so the reward is a content tune rather than a code change.
Write a class-level comment covering D1 and D4, and a method comment on advanceQuest explaining that the derived index is what makes every one of spec §11's softlock cases fall out rather than being special-cased.
- Step 1: Write the failing spec
Build a fake world like shop.service.spec.ts: an in-memory DataSource with a transaction helper that hands the same fake manager to the callback, a stub QuestProgressService, a stub NpcService whose requireReachableNpc resolves or throws, and a spying ReputationService.
Cases — these are spec §13's list, one test each:
it('accepts the quest only from an NPC that offers it', ...)
it('accepts the quest only once', ...) // §13.1
it('rejects a second accept even when the unique index is what refuses it', ...)
it('rejects a talk step at the wrong NPC', ...)
it('sets the referral flag on Borin, not on the warden', ...) // §13.5
it('keeps existing NPC flags when it sets the referral flag', ...)
it('grants the Basic Hide Bag on Borin\'s step', ...) // §13.6
it('does not grant the bag a second time', ...) // §13.11, D1
it('refuses the turn-in while the collect step is short', ...)
it('consumes exactly five pelts on turn-in', ...) // §13.10
it('removes the item row when the last pelt is consumed', ...)
it('completes the quest and stamps completedAt', ...) // §13.11
it('grants the regional reputation reward once', ...)
it('grants no renown milestone and no silver', ...) // D2, D3
it('refuses to advance a completed quest', ...)
it('reports the blocked hint on the active step', ...) // §4, §12
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/api -- quest.service
Expected: FAIL — module not found.
-
Step 3: Implement
quest.errors.ts -
Step 4: Implement
quest.service.ts -
Step 5: Run the spec
Run: npm test --workspace=@ashen-realms/api -- quest.service
Expected: PASS.
- Step 6: Commit
git add apps/api/src/quests
git commit -m "feat(quests): accept, advance and complete a quest chain"
Task 7: NPC quest markers
Files:
- Modify:
apps/api/src/npcs/npc.types.ts - Modify:
apps/api/src/npcs/npc.service.ts - Modify:
apps/api/src/npcs/npc.controller.ts - Modify:
apps/api/src/npcs/npcs.module.ts - Test:
apps/api/src/npcs/npc.service.spec.ts(extend)
Interfaces:
- Consumes:
QuestProgressService.getNpcQuestStates(characterId, npcId, scope?)(Task 4). - Produces:
type NpcMarker = 'MERCHANT' | 'EXCHANGE' | 'QUEST_AVAILABLE' | 'QUEST_IN_PROGRESS' | 'QUEST_TURN_IN'NpcService.getNpcsAtLocation(characterId: string, locationId: string): Promise<NpcSummaryDto[]>— signature change,characterIdfirst (matching every other character-scoped method in the codebase).NpcInteractionDto.availableActionsmay now include{ type: 'VIEW_QUESTS', label: 'Quests', key: null }.
Marker rules (spec §12 asks for exactly three quest markers):
| marker | condition |
|---|---|
QUEST_AVAILABLE |
this NPC has an enabled OFFER assignment for a quest whose status is AVAILABLE |
QUEST_TURN_IN |
a quest is ACTIVE and its derived current objective is a TALK_TO_NPC targeting this NPC — i.e. "your next step is here" |
QUEST_IN_PROGRESS |
this NPC is assigned to an ACTIVE quest but is not the current step |
Order matters: emit at most one quest marker per NPC, preferring QUEST_TURN_IN > QUEST_AVAILABLE > QUEST_IN_PROGRESS, so a busy NPC does not sprout a row of badges. Write that precedence down in a comment — the warden holds both OFFER and TURN_IN, so it is load-bearing, not cosmetic.
resolveActions gains a VIEW_QUESTS action whenever any quest marker applies, which is what merchant-page.component.ts currently ignores with a "no screen until Slice 0.9" comment — remove that comment as part of Task 10.
getNpcsAtLocation and resolveMarkers both take characterId; the controller passes DEMO_CHARACTER_ID, exactly as its sibling method already does. NpcsModule imports QuestProgressModule. Refresh the two stale comments in npc.types.ts that promise quests "in Slice 0.9" — the SUPPORTED_DIALOGUE_ACTIONS one should now state that quest steps run through the quest endpoints instead (D4).
- Step 1: Write the failing spec cases
Extend the existing fake world in npc.service.spec.ts with a stubbed QuestProgressService:
it('marks an NPC that offers an unstarted quest', ...)
it('marks the NPC the current step points at as the turn-in', ...)
it('marks an assigned NPC that is not the current step as in progress', ...)
it('prefers the turn-in marker when one NPC both offers and receives', ...)
it('emits no quest marker for an NPC with no assignment', ...)
it('emits no quest marker once the quest is completed', ...)
it('offers a VIEW_QUESTS action exactly when a quest marker applies', ...)
it('keeps the merchant and exchange markers alongside a quest marker', ...)
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/api -- npc.service
Expected: FAIL.
-
Step 3: Implement
-
Step 4: Run the API suite
Run: npm test --workspace=@ashen-realms/api
Expected: PASS — the signature change touches the controller, so the whole suite is the honest check here.
- Step 5: Commit
git add apps/api/src/npcs
git commit -m "feat(npcs): derive quest markers for the local view"
Task 8: Quest API surface
Files:
- Create:
apps/api/src/quests/quest.controller.ts - Create:
apps/api/src/quests/quests.module.ts - Modify:
apps/api/src/app.module.ts - Test:
apps/api/src/quests/quest.controller.spec.ts
Interfaces:
- Produces:
GET /api/quests -> QuestDto[]
POST /api/npcs/:npcKey/quests/:questKey/accept -> QuestInteractionResultDto
POST /api/npcs/:npcKey/quests/:questKey/advance -> QuestInteractionResultDto
Two controllers in one file, mirroring how ShopController and ExchangeController each bind their own prefix: @Controller('quests') for the log and @Controller('npcs/:npcKey/quests/:questKey') for the two step routes. Both take the character from DEMO_CHARACTER_ID, never from the request — say so in a comment, as ExchangeController does.
Nothing but keys travels in either POST; there is no body at all. Comment that: the server decides which step is current, what it grants and what it consumes (AGENTS.md §5).
QuestsModule imports TypeOrmModule.forFeature([...quest entities, Character, CharacterItem, ItemDefinition, CharacterLootBag, LootBagDefinition, CharacterNpcState, NpcDefinition]), plus QuestProgressModule, NpcsModule, ReputationModule. It provides QuestService and both controllers, and exports QuestService. AppModule imports QuestsModule after NpcsModule.
- Step 1: Write the failing controller spec
A thin spec in the style of reputation.controller.spec.ts: a mocked QuestService, asserting each route delegates with DEMO_CHARACTER_ID and the path params in the right order.
it('returns the quest log for the demo character', ...)
it('accepts a quest through the NPC that offers it', ...)
it('advances a quest step at an NPC', ...)
it('never reads a character id from the request', ...)
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/api -- quest.controller
Expected: FAIL.
-
Step 3: Implement the controllers and module, and register in
AppModule -
Step 4: Run the API suite and build
Run: npm test --workspace=@ashen-realms/api
Then: npm run build:api
Expected: both PASS — the build is what catches a module the DI graph cannot resolve.
- Step 5: Commit
git add apps/api/src
git commit -m "feat(quests): expose the quest log and step endpoints"
Task 9: Web API contracts
Files:
- Modify:
apps/web/src/app/core/api/game-api.models.ts - Modify:
apps/web/src/app/core/api/game-api.service.ts - Test:
apps/web/src/app/core/api/game-api.service.spec.ts(extend)
Interfaces:
- Produces — mirroring the API DTOs one-for-one (AGENTS.md §22: typed contracts, no leaked entities):
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;
hint: string | null;
}
export interface GrantedLootBag {
key: string;
name: string;
lootCategory: string;
capacity: number;
}
export interface QuestInteractionResult {
quest: QuestView;
npcLine: string | null;
grantedBag: GrantedLootBag | null;
consumedItems: Array<{ itemKey: string; quantity: number }>;
rewards: { factionKey: string | null; reputation: number; silver: number } | null;
}
Also extend the existing NpcMarker union with 'QUEST_IN_PROGRESS'.
Service methods:
getQuests(): Observable<QuestView[]> {
return this.http.get<QuestView[]>('/api/quests');
}
acceptQuest(npcKey: string, questKey: string): Observable<QuestInteractionResult> {
return this.http.post<QuestInteractionResult>(
`/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/accept`,
{},
);
}
advanceQuest(npcKey: string, questKey: string): Observable<QuestInteractionResult> {
return this.http.post<QuestInteractionResult>(
`/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/advance`,
{},
);
}
- Step 1: Write the failing spec cases
The existing spec uses HttpTestingController; follow it.
it('reads the quest log from /api/quests', ...)
it('accepts a quest with an empty body', ...) // asserts req.request.body toEqual({})
it('advances a quest step with an empty body', ...)
it('encodes npc and quest keys into the path', ...)
- Step 2: Run and confirm failure
Run: npm test --workspace=@ashen-realms/web -- game-api.service
Expected: FAIL.
-
Step 3: Implement
-
Step 4: Run the spec
Run: npm test --workspace=@ashen-realms/web -- game-api.service
Expected: PASS.
- Step 5: Commit
git add apps/web/src/app/core/api
git commit -m "feat(web): add quest API contracts"
Task 10: Quest journal page
Files:
- Create:
apps/web/src/app/features/quests/quest.store.ts - Create:
apps/web/src/app/features/quests/quest-objective-line.component.{ts,html,scss} - Create:
apps/web/src/app/features/quests/quest-page.component.{ts,html,scss} - Modify:
apps/web/src/app/app.routes.ts - Modify:
apps/web/src/app/layout/side-navigation/side-navigation.component.html - Test:
apps/web/src/app/features/quests/quest.store.spec.ts,apps/web/src/app/features/quests/quest-page.component.spec.ts
Interfaces:
- Consumes:
GameApiService.getQuests()(Task 9). - Produces:
QuestStore—providedIn: 'root', signal-based likeMerchantStore:quests,activeQuests,completedQuests,availableQuests,loading,error,load(),setQuests(quests: QuestView[]).QuestObjectiveLineComponent— selectorapp-quest-objective-line, inputs[objective]: QuestObjectiveViewand[hint]: string | null. RendersCollect Ashen Pelts 1 / 5for a collect step (description +current / required), the bare description for a talk step, and the hint underneath when present. This is the exact block spec §12 shows, and it is shared with the NPC panel in Task 11 so the two can never drift.QuestPageComponent— selectorapp-quest-page, route/quests.
setQuests exists so the NPC page can push the quest it just changed into the journal without a second round trip; the objective-line component stays presentational.
The page renders active quests first (title, description, the current objective line), then completed ones in a muted list, then anything still available. Follow AGENTS.md §19–§20: reuse the existing panel/heading styles from location-sidebar and inventory-page, dark metal surfaces, no white cards, no new visual language. Empty state: You have taken nothing on.
Side navigation: drop disabled and the aria-label="Quests are not yet available" from the data-navigation="quests" button, and give it routerLink="/quests", routerLinkActive, [routerLinkActiveOptions]="{ exact: true }", ariaCurrentWhenActive="page", aria-label="Quests" — matching its siblings exactly.
- Step 1: Write the failing store spec
it('loads the quest log', ...)
it('splits quests by status', ...)
it('keeps the previous log when a reload fails', ...)
it('reports a load error', ...)
it('replaces one quest in place when the NPC screen pushes an update', ...) // setQuests
- Step 2: Write the failing page spec
it('renders the active quest title, description and current objective', ...)
it('renders collect progress as current / required', ...) // "Collect Ashen Pelts 1 / 5"
it('renders the blocked hint under the objective', ...) // spec §12 example block
it('renders an empty state when nothing is taken on', ...)
it('lists completed quests separately', ...)
- Step 3: Run both and confirm failure
Run: npm test --workspace=@ashen-realms/web -- quest
Expected: FAIL.
-
Step 4: Implement store, objective-line component, page, route and navigation
-
Step 5: Run the web suite
Run: npm test --workspace=@ashen-realms/web
Expected: PASS.
- Step 6: Commit
git add apps/web/src/app/features/quests apps/web/src/app/app.routes.ts apps/web/src/app/layout/side-navigation
git commit -m "feat(web): add the quest journal"
Task 11: Quest panel on the NPC screen
Files:
- Modify:
apps/web/src/app/features/npc/merchant.store.ts - Modify:
apps/web/src/app/features/npc/merchant-page.component.{ts,html,scss} - Test:
apps/web/src/app/features/npc/merchant.store.spec.ts,apps/web/src/app/features/npc/merchant-page.component.spec.ts(extend both)
Interfaces:
- Consumes:
GameApiService.getQuests/acceptQuest/advanceQuest(Task 9),QuestStore.setQuestsandQuestObjectiveLineComponent(Task 10). - Produces on
MerchantStore:readonly quests: Signal<QuestView[]>— quests this NPC is involved in, filtered from the log by the NPC's markers being present;readonly questLine: Signal<string | null>;readonly grantedBag: Signal<GrantedLootBag | null>.MerchantPanelgains'QUESTS'.acceptQuest(questKey: string): Promise<void>,advanceQuest(questKey: string): Promise<void>,dismissGrantedBag(): void.
load() additionally fetches getQuests() whenever the interaction's availableActions include VIEW_QUESTS — the same "only fetch panels the server offered" rule the store already applies to shop and exchange. Comment it as such.
acceptQuest/advanceQuest follow the existing buy() shape: guard on pending, call the API, then re-read. After an advance they must re-read the interaction, the shop and the loot capacities, because one step can change the dialogue, unlock the 0.8.5 hide-bag offer through the referral flag, and raise HIDE capacity from 1 to 5 — all at once. Push the returned quest into QuestStore.setQuests so the journal is not stale. Store result.npcLine in questLine and result.grantedBag in grantedBag.
Add the new error codes to the store's ERROR_MESSAGES map:
QUEST_NOT_FOUND: 'This quest could not be found.',
QUEST_NOT_OFFERED_HERE: 'This person has nothing to ask of you.',
QUEST_ALREADY_ACCEPTED: 'You have already taken this on.',
QUEST_NOT_ACTIVE: 'You are not on this quest.',
QUEST_STEP_NOT_HERE: 'This is not what the quest needs from you right now.',
QUEST_OBJECTIVE_INCOMPLETE: 'You do not have what this step needs yet.',
Component: activate() gains a case 'VIEW_QUESTS': this.store.showPanel('QUESTS') and its "no screen until Slice 0.9" comment goes away. The quests panel renders, per quest: title, description, app-quest-objective-line for the current objective, and one action button — Accept when AVAILABLE, Continue when ACTIVE and this NPC is the current step, nothing otherwise. The npcLine renders above the objective in the same voice styling the dialogue node uses.
Bag notice (spec §12, exact text):
New Loot Bag
Basic Hide Bag
Hide Capacity: 5
Render it as a dismissible block driven by grantedBag, reusing whatever the purchase-summary block already looks like — Hide Capacity: 5 is lootCategory title-cased plus capacity, so a future bag needs no new markup.
- Step 1: Write the failing store spec cases
it('loads the quest log only when the NPC offers VIEW_QUESTS', ...)
it('accepts a quest and refreshes the interaction', ...)
it('advances a step and re-reads the shop and capacities', ...)
it('pushes the updated quest into the quest journal', ...)
it('surfaces the NPC line the step returned', ...)
it('holds the granted bag until it is dismissed', ...)
it('maps quest error codes to player-facing text', ...)
it('ignores a second click while a step is pending', ...)
- Step 2: Write the failing page spec cases
it('shows a quests panel when the server offers VIEW_QUESTS', ...)
it('shows Accept for an available quest', ...)
it('shows Continue when this NPC is the current step', ...)
it('shows no action when the current step is elsewhere', ...)
it('renders the new loot bag notice with capacity', ...) // "Hide Capacity: 5"
- Step 3: Run both and confirm failure
Run: npm test --workspace=@ashen-realms/web -- merchant
Expected: FAIL.
-
Step 4: Implement
-
Step 5: Run the web suite
Run: npm test --workspace=@ashen-realms/web
Expected: PASS.
- Step 6: Commit
git add apps/web/src/app/features/npc
git commit -m "feat(web): run quest steps from the NPC screen"
Task 12: Quest markers in the local view
Files:
- Modify:
apps/web/src/app/features/world/world.store.ts - Modify:
apps/web/src/app/features/world/location-page/location-page.component.{ts,html} - Modify:
apps/web/src/app/features/world/location-poi/location-poi.component.{ts,html,scss} - Test:
apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts,apps/web/src/app/features/world/location-page/location-page.component.spec.ts(extend)
Interfaces:
- Consumes:
GameApiService.getLocationNpcs(locationId)(already exists, previously unused by the web app). - Produces:
WorldStore.locationNpcs: Signal<NpcSummary[]>andWorldStore.markersFor(npcKey: string): NpcMarker[].LocationPoiComponentgains@Input() markers: NpcMarker[] = [].
WorldStore.load() (and the post-travel refresh path, which already re-reads the location) fetches the NPC list for the new location id. A failed marker read must never blank the location — swallow it and keep an empty marker list, the same way refreshCharacter keeps the previous character. Comment that: markers are decoration on a screen that must still render.
LocationPageComponent passes store.markersFor(poi.npcKey) when the hotspot carries an npcKey, and [] otherwise.
LocationPoiComponent renders at most one badge on the medallion, using the same precedence the server already applies. Labels and aria-label suffixes:
| marker | badge | aria suffix |
|---|---|---|
QUEST_AVAILABLE |
! |
, quest available |
QUEST_TURN_IN |
? |
, quest step ready |
QUEST_IN_PROGRESS |
· |
, quest in progress |
Style it from the existing medallion tokens — a small metal disc on the hotspot corner, muted gold accent, no new colour language (AGENTS.md §19).
- Step 1: Write the failing POI spec cases
it('renders no badge without markers', ...)
it('renders the quest-available badge', ...)
it('renders the ready badge when the step is here', ...)
it('renders one badge at most', ...)
it('names the marker in the accessible label', ...)
- Step 2: Write the failing page/store spec cases
it('loads NPC markers for the current location', ...)
it('renders the location when the marker read fails', ...)
it('passes markers only to hotspots that name an NPC', ...)
- Step 3: Run and confirm failure
Run: npm test --workspace=@ashen-realms/web -- "location"
Expected: FAIL.
-
Step 4: Implement
-
Step 5: Run the web suite and build
Run: npm test --workspace=@ashen-realms/web
Then: npm run build:web
Expected: both PASS.
- Step 6: Commit
git add apps/web/src/app/features/world
git commit -m "feat(web): show quest markers on location hotspots"
Task 13: Documentation and acceptance verification
Files:
- Modify:
docs/Ashen_Realms_NPC_System_Specification_V1.md(§40 implementation status) - Modify:
docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md(§14 checkboxes)
Interfaces:
- Consumes: everything above.
Update NPC spec §40 in the document's own voice and language (that file is German — match it):
NpcQuestAssignmentis no longer Abweichung 1: it exists atapps/api/src/quests/entities/npc-quest-assignment.entity.tswith theOFFER/TURN_IN/PROGRESSroles from §14, and the quest chain uses two NPCs through it.- Abweichung 3 shrinks:
QUEST_ACTIVEandQUEST_COMPLETEDare now evaluable; onlyBOSS_DEFEATEDandLOCATION_DISCOVEREDremain fail-closed. - Add a new deviation recording D4: the dialogue actions
START_QUEST/COMPLETE_QUESTare still inert. Slice 0.9 §10 forbids a narrative engine and there is no dialogue-response endpoint; quest steps run throughPOST /api/npcs/:npcKey/quests/:questKey/{accept,advance}and the step's line is content on the objective. Note what it would take to close the gap later (a response-selection endpoint plus an action executor) so the decision is reversible rather than forgotten. - Add a short "Implementierungsstand (Slice 0.9)" block listing the new tables and services.
In the slice doc, tick §14's checkboxes only for criteria actually verified, and append a short "Decisions" section recording D1–D5 with their reasoning, so the next slice reads them from the spec rather than from this plan.
-
Step 1: Update the NPC specification
-
Step 2: Update the slice document
-
Step 3: Run the full suite
Run: npm test
Expected: PASS — all API and web suites.
- Step 4: Build both apps
Run: npm run build
Expected: PASS.
- Step 5: Verify the migration against a real database
Run: npm run db:migrate then npm run db:seed
Expected: migration applies cleanly, seed runs twice without duplicating rows. If no Postgres is reachable in this environment, say so explicitly rather than claiming the step passed.
- Step 6: Walk the chain end to end
With the API and web running (npm run dev:api, npm run dev:web), verify each acceptance criterion in spec §14 by hand and record the result:
- South Gate → the warden's hotspot shows a quest-available badge.
- Accept the quest; the journal reads
Collect Ashen Pelts 0 / 5. - Hunt one Ash Rat; capacity strip reads
1 / 1, journal reads1 / 5plus the hint line. - Return to the warden; the referral fires and the objective becomes
Speak with Borin in Graufurt. - Talk to Borin; the new-bag notice reads
Hide Capacity: 5, the capacity strip reads1 / 5, and Borin's shop shows the Basic Hide Bag as unlocked. - Hunt four more pelts; journal reads
5 / 5. - Return to the warden; the pelts are consumed, the quest completes, reputation rises by 10, Renown is unchanged, no XP anywhere.
- Re-open Borin: no second bag.
- Step 7: Commit
git add docs
git commit -m "docs: record the Slice 0.9 quest system and its deviations"
Self-Review
Spec coverage
| Spec section | Task |
|---|---|
| §1 goal / §2 quest concept | 2 |
| §3 narrative step 1 | 2 (dialogue + objective 0) |
| §4 capacity problem | 3 (advanceWhenBlocked), 4 (blocked), 10 (hint rendering) |
| §5 referral to the merchant | 2 (content), 6 (setsFlagNpcKey) |
| §6 merchant interaction, bypass | 2, 6 (D1) |
| §7 continue the hunt, owned pelt counts | 3, 4 |
| §8 turn-in consumes five pelts | 6 |
| §9 rewards, no XP | 2 (content), 6 (grant) — D2, D3 |
| §10 minimal quest system requirements | 1, 3, 4, 6, 8 |
| §11 quest state safety | 3 (derivation), 6 (idempotent grant, locked consumption), 1 (unique index) |
| §12 UI: markers | 7 (API), 12 (web) |
| §12 UI: quest UI | 10 |
| §12 UI: bag UI | 11 |
| §13 tests | 3, 4, 6, 7 (each bullet is a named case) |
| §14 acceptance criteria | 13 |
| §15 out of scope | Nothing in this plan builds any of it |
Type consistency check
QuestObjectiveType, NpcQuestRole, CharacterQuestStatus are declared once in quest.types.ts (Task 1) and imported everywhere. QuestDto / QuestObjectiveDto (Task 6) map field-for-field onto QuestView / QuestObjectiveView (Task 9). resolveCurrentObjectiveIndex keeps one signature across Tasks 3, 4 and 7. getNpcsAtLocation changes signature exactly once, in Task 7, and its only caller is updated in the same task.
Known ripples to watch while executing
- Task 7 changes a public service signature;
npc.service.spec.tsandnpc.controller.tsare the only call sites, but run the whole API suite there rather than a filtered one. - Task 5 widens
SUPPORTED_CONDITION_TYPES, which the shop's locked-offer presentation reads.offer-presentation.tsmust keep rendering a quest requirement sensibly (actual: null) — if it does not, fix it in Task 5 rather than leaving it for the UI. - Task 2 removes the demo character's Hide Bag. Any existing test that assumes HIDE capacity 5 for the demo character will fail; that failure is the point, and the assertion should be updated to 1.