diff --git a/apps/api/src/npcs/npc.controller.ts b/apps/api/src/npcs/npc.controller.ts index 63fece6..1a589a4 100644 --- a/apps/api/src/npcs/npc.controller.ts +++ b/apps/api/src/npcs/npc.controller.ts @@ -12,7 +12,7 @@ export class NpcController { getNpcsAtLocation( @Param('locationId') locationId: string, ): Promise { - return this.npcService.getNpcsAtLocation(locationId); + return this.npcService.getNpcsAtLocation(DEMO_CHARACTER_ID, locationId); } /** diff --git a/apps/api/src/npcs/npc.service.spec.ts b/apps/api/src/npcs/npc.service.spec.ts index 9bc5a75..58f7454 100644 --- a/apps/api/src/npcs/npc.service.spec.ts +++ b/apps/api/src/npcs/npc.service.spec.ts @@ -7,6 +7,11 @@ import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; import { NpcDefinition } from './entities/npc-definition.entity'; +import { + NpcQuestState, + QuestProgressService, +} from '../quests/quest-progress.service'; +import { NpcQuestRole, QuestObjectiveType } from '../quests/quest.types'; import { NpcService } from './npc.service'; import { NpcCapability } from './npc.types'; @@ -24,6 +29,12 @@ interface Fixture { hasExchangeProfile?: boolean; exchangeRuleCount?: number; existingState?: Record | null; + questSituation?: + | 'offers' + | 'offers-and-receives' + | 'step-is-here' + | 'step-is-elsewhere' + | 'completed'; } function createWorld(fixture: Fixture = {}) { @@ -124,7 +135,79 @@ function createWorld(fixture: Fixture = {}) { }), } as unknown as GameConditionService; - return { service: new NpcService(dataSource, conditions), savedStates, npc }; + const questProgress = { + getNpcQuestStates: jest + .fn() + .mockResolvedValue(questStatesFor(fixture)), + } as unknown as QuestProgressService; + + return { + service: new NpcService(dataSource, conditions, questProgress), + savedStates, + npc, + }; +} + +/** + * The quest situations `QuestProgressService` can report for one NPC. + * + * Kept as a fixture switch rather than a hand-built state per test: what + * `NpcService` does with a quest state is a small precedence decision, and the + * derivation itself is already covered by `quest-progress.service.spec.ts`. + */ +function questStatesFor(fixture: Fixture): NpcQuestState[] { + const quest = { id: 'quest-1', key: 'trouble-beyond-the-gate' }; + + const build = ( + status: 'AVAILABLE' | 'ACTIVE' | 'COMPLETED', + currentIndex: number | null, + roles: NpcQuestRole[], + ): NpcQuestState => + ({ + state: { + quest, + status, + currentIndex, + row: null, + objectives: [ + { + objective: { + key: 'collect', + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + }, + }, + { + objective: { + key: 'talk', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'borin-quartermaster', + }, + }, + ], + }, + roles, + }) as unknown as NpcQuestState; + + switch (fixture.questSituation) { + case 'offers': + return [build('AVAILABLE', null, [NpcQuestRole.OFFER])]; + case 'offers-and-receives': + // One person with something to start *and* a step waiting: exactly what + // the South Gate Warden looks like mid-chain (NPC spec §14). + return [ + build('AVAILABLE', null, [NpcQuestRole.OFFER]), + build('ACTIVE', 1, [NpcQuestRole.TURN_IN]), + ]; + case 'step-is-here': + return [build('ACTIVE', 1, [NpcQuestRole.TURN_IN])]; + case 'step-is-elsewhere': + return [build('ACTIVE', 0, [NpcQuestRole.PROGRESS])]; + case 'completed': + return [build('COMPLETED', null, [NpcQuestRole.OFFER])]; + default: + return []; + } } function node(overrides: Partial): Partial { @@ -146,7 +229,10 @@ describe('NpcService', () => { it('lists the people at a location with their markers', async () => { const world = createWorld(); - const npcs = await world.service.getNpcsAtLocation(LOCATION_ID); + const npcs = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); expect(npcs).toHaveLength(1); expect(npcs[0]).toMatchObject({ @@ -156,6 +242,109 @@ describe('NpcService', () => { expect(npcs[0].markers).toEqual(['MERCHANT', 'EXCHANGE']); }); + it('marks an NPC that has a quest to give', async () => { + const world = createWorld({ questSituation: 'offers' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_AVAILABLE'); + }); + + it('marks the NPC the current step points at', async () => { + const world = createWorld({ questSituation: 'step-is-here' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_TURN_IN'); + }); + + it('marks an assigned NPC whose step is elsewhere as in progress', async () => { + const world = createWorld({ questSituation: 'step-is-elsewhere' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_IN_PROGRESS'); + }); + + it('prefers the waiting step over a quest that is merely available', async () => { + // Otherwise the warden mid-chain shows two badges and neither tells the + // player where to go. + const world = createWorld({ questSituation: 'offers-and-receives' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_TURN_IN'); + expect(borin.markers).not.toContain('QUEST_AVAILABLE'); + }); + + it('emits at most one quest marker', async () => { + const world = createWorld({ questSituation: 'offers-and-receives' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect( + borin.markers.filter((marker) => marker.startsWith('QUEST_')), + ).toHaveLength(1); + }); + + it('emits no quest marker for an NPC with no assignment', async () => { + const world = createWorld(); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect( + borin.markers.some((marker) => marker.startsWith('QUEST_')), + ).toBe(false); + }); + + it('emits no quest marker once the quest is finished', async () => { + const world = createWorld({ questSituation: 'completed' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect( + borin.markers.some((marker) => marker.startsWith('QUEST_')), + ).toBe(false); + }); + + it('offers a quests action exactly when a quest marker applies', async () => { + const withQuest = await createWorld({ + questSituation: 'offers', + }).service.getInteraction(CHARACTER_ID, 'borin-quartermaster'); + const withoutQuest = await createWorld().service.getInteraction( + CHARACTER_ID, + 'borin-quartermaster', + ); + + expect(withQuest.availableActions.map((action) => action.type)).toContain( + 'VIEW_QUESTS', + ); + expect( + withoutQuest.availableActions.map((action) => action.type), + ).not.toContain('VIEW_QUESTS'); + }); + it('refuses an NPC the character has not travelled to', async () => { // Reachability comes from the character's own location, never the request. const world = createWorld({ characterLocationId: 'somewhere-else' }); diff --git a/apps/api/src/npcs/npc.service.ts b/apps/api/src/npcs/npc.service.ts index 87f322a..83379bd 100644 --- a/apps/api/src/npcs/npc.service.ts +++ b/apps/api/src/npcs/npc.service.ts @@ -4,6 +4,8 @@ import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity'; +import { QuestProgressService } from '../quests/quest-progress.service'; +import { NpcQuestRole, QuestObjectiveType } from '../quests/quest.types'; import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; @@ -30,10 +32,19 @@ export class NpcService { constructor( private readonly dataSource: DataSource, private readonly conditions: GameConditionService, + private readonly questProgress: QuestProgressService, ) {} - /** Every enabled NPC at a location, for the local view (spec §22, §24). */ - async getNpcsAtLocation(locationId: string): Promise { + /** + * Every enabled NPC at a location, for the local view (spec §22, §24). + * + * Takes the character because markers are per-player: whether the warden has + * something to ask depends on what this character has already done. + */ + async getNpcsAtLocation( + characterId: string, + locationId: string, + ): Promise { const npcs = await this.dataSource.getRepository(NpcDefinition).find({ where: { locationId, enabled: true }, order: { key: 'ASC' }, @@ -47,7 +58,7 @@ export class NpcService { name: npc.name, title: npc.title, portraitPath: npc.portraitPath, - markers: await this.resolveMarkers(npc), + markers: await this.resolveMarkers(characterId, npc), }); } return summaries; @@ -84,7 +95,7 @@ export class NpcService { capabilities: npc.capabilities ?? [], }, dialogue, - availableActions: await this.resolveActions(npc), + availableActions: await this.resolveActions(characterId, npc), }; } @@ -165,7 +176,10 @@ export class NpcService { * declared capability list -- an NPC that claims MERCHANT but has no * enabled shop offers no shop button (spec §5). */ - private async resolveActions(npc: NpcDefinition): Promise { + private async resolveActions( + characterId: string, + npc: NpcDefinition, + ): Promise { const actions: NpcActionDto[] = [ { type: 'TALK', label: 'Talk', key: null }, ]; @@ -186,11 +200,21 @@ export class NpcService { }); } + // Offered on the same rule as the marker: if this person has nothing to + // say about a quest, the screen shows one fewer button rather than an + // empty panel. + if ((await this.resolveQuestMarker(characterId, npc)) !== null) { + actions.push({ type: 'VIEW_QUESTS', label: 'Quests', key: null }); + } + return actions; } /** Markers for the local view. Only backed interactions get one (spec §24). */ - private async resolveMarkers(npc: NpcDefinition): Promise { + private async resolveMarkers( + characterId: string, + npc: NpcDefinition, + ): Promise { const markers: NpcMarker[] = []; const shop = await this.dataSource @@ -204,9 +228,74 @@ export class NpcService { markers.push('EXCHANGE'); } + const quest = await this.resolveQuestMarker(characterId, npc); + if (quest) { + markers.push(quest); + } + return markers; } + /** + * The one quest marker this NPC earns right now, or null (Slice 0.9 §12). + * + * Precedence is load-bearing rather than cosmetic: the South Gate Warden both + * offers this quest and receives it, so without an order they would show two + * badges at once and the player would learn nothing from either. "Your next + * step is here" beats "something starts here" beats "you are on a quest this + * person is part of". + * + * Read through `QuestProgressService` rather than by querying the quest + * tables directly, so "which step am I on" is answered in exactly one place + * -- including the capacity-blocked case, which is the whole reason the + * warden lights up while the player is stuck at 1 / 5. + */ + private async resolveQuestMarker( + characterId: string, + npc: NpcDefinition, + ): Promise { + const entries = await this.questProgress.getNpcQuestStates( + characterId, + npc.id, + ); + if (entries.length === 0) { + return null; + } + + let available = false; + let inProgress = false; + + for (const { state, roles } of entries) { + if (state.status === 'ACTIVE') { + const currentIndex = state.currentIndex; + const step = + currentIndex === null + ? undefined + : state.objectives[currentIndex]?.objective; + + // Matched on the NPC's business key, because that is what a talk step + // names -- content should not have to know generated ids. + if ( + step?.type === QuestObjectiveType.TALK_TO_NPC && + step.targetKey === npc.key + ) { + return 'QUEST_TURN_IN'; + } + inProgress = true; + continue; + } + + if (state.status === 'AVAILABLE' && roles.includes(NpcQuestRole.OFFER)) { + available = true; + } + } + + if (available) { + return 'QUEST_AVAILABLE'; + } + return inProgress ? 'QUEST_IN_PROGRESS' : null; + } + /** * An enabled exchange profile that actually has an enabled rule. * diff --git a/apps/api/src/npcs/npc.types.ts b/apps/api/src/npcs/npc.types.ts index a53ce26..de90a8b 100644 --- a/apps/api/src/npcs/npc.types.ts +++ b/apps/api/src/npcs/npc.types.ts @@ -27,10 +27,13 @@ export enum NpcCapability { /** * Actions a dialogue node may trigger (spec §13). * - * START_QUEST and COMPLETE_QUEST are part of the V1 vocabulary but have no - * quest system behind them yet (Slice 0.9). They are listed so content and - * the stored enum do not need rewriting later; `NpcService` refuses to offer - * an action it cannot actually carry out. + * START_QUEST and COMPLETE_QUEST stay inert even now that quests exist + * (Slice 0.9 decision D4). Carrying them out would need a dialogue-response + * endpoint and an action executor, and Slice 0.9 §10 rules out building a + * branching narrative engine for one tutorial chain. Quest steps run through + * `POST /api/npcs/:npcKey/quests/:questKey/{accept,advance}` instead, and the + * line for a step is content on the objective. They stay listed so the stored + * enum does not need rewriting if that changes. */ export enum DialogueActionType { OPEN_SHOP = 'OPEN_SHOP', @@ -76,13 +79,23 @@ export interface NpcSummaryDto { } /** - * Presentation markers (spec §24). + * Presentation markers (spec §24, Slice 0.9 §12). * * Only markers backed by a real, currently available interaction are emitted. * The view does not turn every capability into a permanent symbol. + * + * The three quest markers answer three different questions, in this order of + * usefulness: `QUEST_TURN_IN` means "your next step is here", `QUEST_AVAILABLE` + * means "something starts here", and `QUEST_IN_PROGRESS` means "this person is + * part of a quest you are on, but not right now". At most one is emitted per + * NPC -- a row of badges on one portrait tells the player nothing. */ export type NpcMarker = - 'MERCHANT' | 'EXCHANGE' | 'QUEST_AVAILABLE' | 'QUEST_TURN_IN'; + | 'MERCHANT' + | 'EXCHANGE' + | 'QUEST_AVAILABLE' + | 'QUEST_IN_PROGRESS' + | 'QUEST_TURN_IN'; export interface DialogueResponseDto { key: string; diff --git a/apps/api/src/npcs/npcs.module.ts b/apps/api/src/npcs/npcs.module.ts index a73bf7e..c5817ff 100644 --- a/apps/api/src/npcs/npcs.module.ts +++ b/apps/api/src/npcs/npcs.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { ConditionsModule } from '../conditions/conditions.module'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity'; +import { QuestProgressModule } from '../quests/quest-progress.module'; import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; @@ -31,6 +32,9 @@ import { NpcService } from './npc.service'; ExchangeRule, ]), ConditionsModule, + // The read-only half of the quest system, which exists as its own module + // precisely so this import does not close a cycle with `QuestsModule`. + QuestProgressModule, ], controllers: [NpcController], providers: [NpcService],