# 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.forFeature` even when the service resolves repositories off `DataSource` — the runtime uses `autoLoadEntities`. ## 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, `bypassConditions` on 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 sets `referred-by-south-gate-warden` on 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-returned` takes 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_QUEST` dialogue actions, but there is no dialogue-response endpoint at all today and spec §10 forbids a narrative engine. Steps are triggered by `POST /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.ts` already 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 for `DEMO_CHARACTER_ID` on 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) ```text 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 (`setsFlagKey` on `setsFlagNpcKey`'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.5 `bypassConditions` gate 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_key` unique on `key` - `IDX_quest_objectives_quest_key` unique on (`quest_id`, `key`) - `IDX_quest_objectives_quest_order` unique on (`quest_id`, `order_index`) - `IDX_npc_quest_assignments_npc_quest_role` unique on (`npc_id`, `quest_id`, `role`) - `IDX_npc_quest_assignments_npc` on (`npc_id`) - `IDX_character_quests_character_quest` unique 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 quests `ON DELETE CASCADE`; character_quests → characters `ON DELETE CASCADE`, → quests `ON DELETE RESTRICT` - `CHK_quest_objectives_required_quantity` CHECK `required_quantity >= 1` - `CHK_character_quests_objective_index` CHECK `current_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: ```sql -- 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: ```ts 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.ts` and 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** ```bash 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-watch` POI → 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_ID` from `npc-content.ts`; `ITEM_IDS` / `'ash-pelt'`; `'basic-hide-bag'` from `loot-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 SeedNpcQuestAssignment` - `QUEST_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):** ```text 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: ```text §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`): ```text 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_DEFINITIONS` on `['key']`, `QUEST_OBJECTIVES` on `['id']` (an objective has no natural key across quests and its `key` is only unique per quest), `NPC_QUEST_ASSIGNMENTS` on `['id']`. Order: after `npcDefinitionRepository.upsert`, because assignments reference NPC ids. - Delete the `characterLootBagRepository` block that grants the Basic Hide Bag (D5) together with its now-obsolete ASSUMPTION comment. Remove the `BASIC_HIDE_BAG_ID` and `CharacterLootBag` imports 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): ```ts 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`, extend `npc-content.ts` and `local-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** ```bash 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: ```ts /** 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** ```ts const collect = (over: Partial = {}): 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: ```ts 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** ```bash 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: ```ts 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; async getQuestState(characterId: string, questKey: string, scope?: RepositoryScope): Promise; /** Quest states for the quests a given NPC is assigned to, with the roles. */ async getNpcQuestStates( characterId: string, npcId: string, scope?: RepositoryScope, ): Promise>; } ``` `RepositoryScope` is the repo-wide `Pick` 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_ITEM` step: `ItemDefinition` by `targetKey`, then `CharacterItem.quantity` for that definition; a missing row reads as 0. - `blocked`: the target item's `lootCategory`; null category → never blocked (equipment and consumables are unaffected by bags, Slice 0.7.5 §8). Otherwise blocked when the matching `LootCapacityService` entry has `current >= capacity` **and** the step is not already satisfied. - `status`: no `CharacterQuest` row → `AVAILABLE`; row status maps straight through. - `currentIndex`: `resolveCurrentObjectiveIndex(snapshots, row.currentObjectiveIndex)` when ACTIVE, else null. - Only `enabled` quests and `enabled` objectives are loaded; objectives are ordered by `orderIndex 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[]`. ```ts 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** ```bash 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_TYPES` additionally contains `QUEST_ACTIVE` and `QUEST_COMPLETED`. Both evaluate like `FLAG_SET` — a boolean with an expected value, not a numeric comparison: ```ts private async evaluateQuestStatus( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, status: CharacterQuestStatus, ): Promise { 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** ```ts 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** ```bash 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: ```ts 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; async acceptQuest(characterId: string, npcKey: string, questKey: string): Promise; async advanceQuest(characterId: string, npcKey: string, questKey: string): Promise; } ``` 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`:** 1. `requireReachableNpc` — the character must be standing with the NPC (never trust the request for location). 2. Load the quest by key; enabled only, else `QUEST_NOT_FOUND`. 3. Require an enabled `NpcQuestAssignment(npcId, questId, role: OFFER)`, else `QUEST_NOT_OFFERED_HERE`. 4. 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). 5. Return the fresh `QuestDto` with `npcLine: null` — the offer line is dialogue content and the NPC screen re-reads it (see D4 note). **`advanceQuest`:** 1. `requireReachableNpc`, load quest, else `QUEST_NOT_FOUND`. 2. Whole body inside `dataSource.transaction`, taking a pessimistic write lock on the `CharacterQuest` row first, so two clicks cannot both consume pelts (AGENTS.md §29, §30). 3. Status must be `ACTIVE`, else `QUEST_NOT_ACTIVE`. 4. Derive the state through `QuestProgressService` **using the transaction manager**. The current objective must be `TALK_TO_NPC` with `targetKey === npc.key`, else `QUEST_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. 5. Persist the floor: `row.currentObjectiveIndex = currentIndex + 1`. 6. Apply the step's effects in this order: - **`setsFlagKey`/`setsFlagNpcKey`** — resolve the target NPC by key, upsert its `CharacterNpcState`, merge `{ [setsFlagKey]: true }` into `flags`. Merging, never replacing: `met` and any other flag on that row must survive (spec §11 "referral flag persists"). - **`grantsLootBagKey`** — resolve the `LootBagDefinition` by key; if the character already holds it, skip silently and return `grantedBag: null`; otherwise insert `CharacterLootBag { active: true }` and return the bag. Idempotent by construction (spec §11, §13, D1). - **Completion** — when `currentIndex + 1 >= objectives.length`: consume, reward, and set `status: COMPLETED`, `completedAt: now`. 7. Consumption walks every objective with `consumeOnComplete`, locking each `CharacterItem` row and requiring `quantity >= requiredQuantity`, else `QUEST_OBJECTIVE_INCOMPLETE`. Delete the row when the quantity hits exactly zero, otherwise decrement — mirroring `ExchangeService.consumeItems`, which is the existing precedent for this shape. 8. 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: ```ts 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** ```bash 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` — **signature change**, `characterId` first (matching every other character-scoped method in the codebase). - `NpcInteractionDto.availableActions` may 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`: ```ts 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** ```bash 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: ```text 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. ```ts 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** ```bash 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): ```ts 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: ```ts getQuests(): Observable { return this.http.get('/api/quests'); } acceptQuest(npcKey: string, questKey: string): Observable { return this.http.post( `/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/accept`, {}, ); } advanceQuest(npcKey: string, questKey: string): Observable { return this.http.post( `/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/advance`, {}, ); } ``` - [ ] **Step 1: Write the failing spec cases** The existing spec uses `HttpTestingController`; follow it. ```ts 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** ```bash 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 like `MerchantStore`: `quests`, `activeQuests`, `completedQuests`, `availableQuests`, `loading`, `error`, `load()`, `setQuests(quests: QuestView[])`. - `QuestObjectiveLineComponent` — selector `app-quest-objective-line`, inputs `[objective]: QuestObjectiveView` and `[hint]: string | null`. Renders `Collect Ashen Pelts 1 / 5` for 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` — selector `app-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** ```ts 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** ```ts 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** ```bash 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.setQuests` and `QuestObjectiveLineComponent` (Task 10). - Produces on `MerchantStore`: - `readonly quests: Signal` — quests this NPC is involved in, filtered from the log by the NPC's markers being present; `readonly questLine: Signal`; `readonly grantedBag: Signal`. - `MerchantPanel` gains `'QUESTS'`. - `acceptQuest(questKey: string): Promise`, `advanceQuest(questKey: string): Promise`, `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: ```ts 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): ```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** ```ts 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** ```ts 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** ```bash 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` and `WorldStore.markersFor(npcKey: string): NpcMarker[]`. - `LocationPoiComponent` gains `@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** ```ts 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** ```ts 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** ```bash 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): - `NpcQuestAssignment` is no longer Abweichung 1: it exists at `apps/api/src/quests/entities/npc-quest-assignment.entity.ts` with the `OFFER`/`TURN_IN`/`PROGRESS` roles from §14, and the quest chain uses two NPCs through it. - Abweichung 3 shrinks: `QUEST_ACTIVE` and `QUEST_COMPLETED` are now evaluable; only `BOSS_DEFEATED` and `LOCATION_DISCOVERED` remain fail-closed. - Add a new deviation recording **D4**: the dialogue actions `START_QUEST` / `COMPLETE_QUEST` are still inert. Slice 0.9 §10 forbids a narrative engine and there is no dialogue-response endpoint; quest steps run through `POST /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: 1. South Gate → the warden's hotspot shows a quest-available badge. 2. Accept the quest; the journal reads `Collect Ashen Pelts 0 / 5`. 3. Hunt one Ash Rat; capacity strip reads `1 / 1`, journal reads `1 / 5` plus the hint line. 4. Return to the warden; the referral fires and the objective becomes `Speak with Borin in Graufurt`. 5. Talk to Borin; the new-bag notice reads `Hide Capacity: 5`, the capacity strip reads `1 / 5`, and Borin's shop shows the Basic Hide Bag as unlocked. 6. Hunt four more pelts; journal reads `5 / 5`. 7. Return to the warden; the pelts are consumed, the quest completes, reputation rises by 10, Renown is unchanged, no XP anywhere. 8. Re-open Borin: no second bag. - [ ] **Step 7: Commit** ```bash 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.ts` and `npc.controller.ts` are 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.ts` must 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.