feat(npcs): derive quest markers for the local view

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 22:59:22 +02:00
parent b9dafd56ff
commit 8f37c15654
5 changed files with 310 additions and 15 deletions

View File

@@ -12,7 +12,7 @@ export class NpcController {
getNpcsAtLocation( getNpcsAtLocation(
@Param('locationId') locationId: string, @Param('locationId') locationId: string,
): Promise<NpcSummaryDto[]> { ): Promise<NpcSummaryDto[]> {
return this.npcService.getNpcsAtLocation(locationId); return this.npcService.getNpcsAtLocation(DEMO_CHARACTER_ID, locationId);
} }
/** /**

View File

@@ -7,6 +7,11 @@ import { NpcShop } from '../shops/entities/npc-shop.entity';
import { CharacterNpcState } from './entities/character-npc-state.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity';
import { DialogueNode } from './entities/dialogue-node.entity'; import { DialogueNode } from './entities/dialogue-node.entity';
import { NpcDefinition } from './entities/npc-definition.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 { NpcService } from './npc.service';
import { NpcCapability } from './npc.types'; import { NpcCapability } from './npc.types';
@@ -24,6 +29,12 @@ interface Fixture {
hasExchangeProfile?: boolean; hasExchangeProfile?: boolean;
exchangeRuleCount?: number; exchangeRuleCount?: number;
existingState?: Record<string, unknown> | null; existingState?: Record<string, unknown> | null;
questSituation?:
| 'offers'
| 'offers-and-receives'
| 'step-is-here'
| 'step-is-elsewhere'
| 'completed';
} }
function createWorld(fixture: Fixture = {}) { function createWorld(fixture: Fixture = {}) {
@@ -124,7 +135,79 @@ function createWorld(fixture: Fixture = {}) {
}), }),
} as unknown as GameConditionService; } 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<DialogueNode>): Partial<DialogueNode> { function node(overrides: Partial<DialogueNode>): Partial<DialogueNode> {
@@ -146,7 +229,10 @@ describe('NpcService', () => {
it('lists the people at a location with their markers', async () => { it('lists the people at a location with their markers', async () => {
const world = createWorld(); 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).toHaveLength(1);
expect(npcs[0]).toMatchObject({ expect(npcs[0]).toMatchObject({
@@ -156,6 +242,109 @@ describe('NpcService', () => {
expect(npcs[0].markers).toEqual(['MERCHANT', 'EXCHANGE']); 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 () => { it('refuses an NPC the character has not travelled to', async () => {
// Reachability comes from the character's own location, never the request. // Reachability comes from the character's own location, never the request.
const world = createWorld({ characterLocationId: 'somewhere-else' }); const world = createWorld({ characterLocationId: 'somewhere-else' });

View File

@@ -4,6 +4,8 @@ import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service'; import { GameConditionService } from '../conditions/game-condition.service';
import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.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 { NpcShop } from '../shops/entities/npc-shop.entity';
import { CharacterNpcState } from './entities/character-npc-state.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity';
import { DialogueNode } from './entities/dialogue-node.entity'; import { DialogueNode } from './entities/dialogue-node.entity';
@@ -30,10 +32,19 @@ export class NpcService {
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly conditions: GameConditionService, 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<NpcSummaryDto[]> { * 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<NpcSummaryDto[]> {
const npcs = await this.dataSource.getRepository(NpcDefinition).find({ const npcs = await this.dataSource.getRepository(NpcDefinition).find({
where: { locationId, enabled: true }, where: { locationId, enabled: true },
order: { key: 'ASC' }, order: { key: 'ASC' },
@@ -47,7 +58,7 @@ export class NpcService {
name: npc.name, name: npc.name,
title: npc.title, title: npc.title,
portraitPath: npc.portraitPath, portraitPath: npc.portraitPath,
markers: await this.resolveMarkers(npc), markers: await this.resolveMarkers(characterId, npc),
}); });
} }
return summaries; return summaries;
@@ -84,7 +95,7 @@ export class NpcService {
capabilities: npc.capabilities ?? [], capabilities: npc.capabilities ?? [],
}, },
dialogue, 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 * declared capability list -- an NPC that claims MERCHANT but has no
* enabled shop offers no shop button (spec §5). * enabled shop offers no shop button (spec §5).
*/ */
private async resolveActions(npc: NpcDefinition): Promise<NpcActionDto[]> { private async resolveActions(
characterId: string,
npc: NpcDefinition,
): Promise<NpcActionDto[]> {
const actions: NpcActionDto[] = [ const actions: NpcActionDto[] = [
{ type: 'TALK', label: 'Talk', key: null }, { 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; return actions;
} }
/** Markers for the local view. Only backed interactions get one (spec §24). */ /** Markers for the local view. Only backed interactions get one (spec §24). */
private async resolveMarkers(npc: NpcDefinition): Promise<NpcMarker[]> { private async resolveMarkers(
characterId: string,
npc: NpcDefinition,
): Promise<NpcMarker[]> {
const markers: NpcMarker[] = []; const markers: NpcMarker[] = [];
const shop = await this.dataSource const shop = await this.dataSource
@@ -204,9 +228,74 @@ export class NpcService {
markers.push('EXCHANGE'); markers.push('EXCHANGE');
} }
const quest = await this.resolveQuestMarker(characterId, npc);
if (quest) {
markers.push(quest);
}
return markers; 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<NpcMarker | null> {
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. * An enabled exchange profile that actually has an enabled rule.
* *

View File

@@ -27,10 +27,13 @@ export enum NpcCapability {
/** /**
* Actions a dialogue node may trigger (spec §13). * Actions a dialogue node may trigger (spec §13).
* *
* START_QUEST and COMPLETE_QUEST are part of the V1 vocabulary but have no * START_QUEST and COMPLETE_QUEST stay inert even now that quests exist
* quest system behind them yet (Slice 0.9). They are listed so content and * (Slice 0.9 decision D4). Carrying them out would need a dialogue-response
* the stored enum do not need rewriting later; `NpcService` refuses to offer * endpoint and an action executor, and Slice 0.9 §10 rules out building a
* an action it cannot actually carry out. * 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 { export enum DialogueActionType {
OPEN_SHOP = 'OPEN_SHOP', 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. * Only markers backed by a real, currently available interaction are emitted.
* The view does not turn every capability into a permanent symbol. * 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 = export type NpcMarker =
'MERCHANT' | 'EXCHANGE' | 'QUEST_AVAILABLE' | 'QUEST_TURN_IN'; | 'MERCHANT'
| 'EXCHANGE'
| 'QUEST_AVAILABLE'
| 'QUEST_IN_PROGRESS'
| 'QUEST_TURN_IN';
export interface DialogueResponseDto { export interface DialogueResponseDto {
key: string; key: string;

View File

@@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity';
import { ConditionsModule } from '../conditions/conditions.module'; import { ConditionsModule } from '../conditions/conditions.module';
import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.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 { NpcShop } from '../shops/entities/npc-shop.entity';
import { CharacterNpcState } from './entities/character-npc-state.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity';
import { DialogueNode } from './entities/dialogue-node.entity'; import { DialogueNode } from './entities/dialogue-node.entity';
@@ -31,6 +32,9 @@ import { NpcService } from './npc.service';
ExchangeRule, ExchangeRule,
]), ]),
ConditionsModule, 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], controllers: [NpcController],
providers: [NpcService], providers: [NpcService],