Files
ashen-realms/docs/superpowers/plans/2026-08-22-first-quest-and-bag-tutorial.md
2026-08-22 22:25:26 +02:00

68 KiB
Raw Blame History

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, §1921 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 (§68 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)

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:

-- 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.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
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):

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_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.pngapps/web/public/images/npcs/south-gate-warden.png and art/npc/Graufurt_Haendler.pngapps/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, 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
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_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[].

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_TYPES additionally contains QUEST_ACTIVE and QUEST_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:

  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 > 0ReputationService.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, 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:

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:
    • QuestStoreprovidedIn: '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
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.setQuests and QuestObjectiveLineComponent (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>.
    • MerchantPanel gains '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[]> 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
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):

  • 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 D1D5 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
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.