feat(npcs): derive quest markers for the local view
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ export class NpcController {
|
||||
getNpcsAtLocation(
|
||||
@Param('locationId') locationId: string,
|
||||
): Promise<NpcSummaryDto[]> {
|
||||
return this.npcService.getNpcsAtLocation(locationId);
|
||||
return this.npcService.getNpcsAtLocation(DEMO_CHARACTER_ID, locationId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> | 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<DialogueNode>): Partial<DialogueNode> {
|
||||
@@ -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' });
|
||||
|
||||
@@ -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<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({
|
||||
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<NpcActionDto[]> {
|
||||
private async resolveActions(
|
||||
characterId: string,
|
||||
npc: NpcDefinition,
|
||||
): Promise<NpcActionDto[]> {
|
||||
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<NpcMarker[]> {
|
||||
private async resolveMarkers(
|
||||
characterId: string,
|
||||
npc: NpcDefinition,
|
||||
): Promise<NpcMarker[]> {
|
||||
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<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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user