docs
This commit is contained in:
266
apps/api/src/npcs/npc.service.ts
Normal file
266
apps/api/src/npcs/npc.service.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
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 { 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 { npcNotFound, npcUnavailable } from './npc.errors';
|
||||
import {
|
||||
DialogueNodeDto,
|
||||
DialogueResponseDto,
|
||||
NpcActionDto,
|
||||
NpcInteractionDto,
|
||||
NpcMarker,
|
||||
NpcSummaryDto,
|
||||
} from './npc.types';
|
||||
|
||||
/**
|
||||
* Loads NPCs and works out what they currently offer (NPC spec §30).
|
||||
*
|
||||
* Deliberately not responsible for purchases, exchanges, reputation or item
|
||||
* transfer -- those live in their own services (spec §30). This one answers
|
||||
* "who is here, what do they say, and what can I do with them".
|
||||
*/
|
||||
@Injectable()
|
||||
export class NpcService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly conditions: GameConditionService,
|
||||
) {}
|
||||
|
||||
/** Every enabled NPC at a location, for the local view (spec §22, §24). */
|
||||
async getNpcsAtLocation(locationId: string): Promise<NpcSummaryDto[]> {
|
||||
const npcs = await this.dataSource.getRepository(NpcDefinition).find({
|
||||
where: { locationId, enabled: true },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
|
||||
const summaries: NpcSummaryDto[] = [];
|
||||
for (const npc of npcs) {
|
||||
summaries.push({
|
||||
id: npc.id,
|
||||
key: npc.key,
|
||||
name: npc.name,
|
||||
title: npc.title,
|
||||
portraitPath: npc.portraitPath,
|
||||
markers: await this.resolveMarkers(npc),
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the client needs to render one NPC screen (spec §23).
|
||||
*
|
||||
* The character's own location decides reachability, never the request, so
|
||||
* a client cannot talk to a merchant in a town it has not travelled to
|
||||
* (the same rule `WorldService.runLocalInteraction` applies).
|
||||
*/
|
||||
async getInteraction(
|
||||
characterId: string,
|
||||
npcKey: string,
|
||||
): Promise<NpcInteractionDto> {
|
||||
const npc = await this.requireReachableNpc(characterId, npcKey);
|
||||
|
||||
// Dialogue is resolved *before* the visit is recorded. `touchNpcState`
|
||||
// sets the `met` flag, so a greeting node conditioned on `met = false`
|
||||
// would never fire if the order were reversed.
|
||||
const dialogue = await this.resolveDialogue(characterId, npc);
|
||||
await this.touchNpcState(characterId, npc.id);
|
||||
|
||||
return {
|
||||
npc: {
|
||||
id: npc.id,
|
||||
key: npc.key,
|
||||
name: npc.name,
|
||||
title: npc.title,
|
||||
description: npc.description,
|
||||
portraitPath: npc.portraitPath,
|
||||
artworkPath: npc.artworkPath,
|
||||
capabilities: npc.capabilities ?? [],
|
||||
},
|
||||
dialogue,
|
||||
availableActions: await this.resolveActions(npc),
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads an enabled NPC the character is currently standing with. */
|
||||
async requireReachableNpc(
|
||||
characterId: string,
|
||||
npcKey: string,
|
||||
): Promise<NpcDefinition> {
|
||||
const npc = await this.dataSource
|
||||
.getRepository(NpcDefinition)
|
||||
.findOneBy({ key: npcKey, enabled: true });
|
||||
if (!npc) {
|
||||
throw npcNotFound();
|
||||
}
|
||||
|
||||
const character = await this.dataSource
|
||||
.getRepository(Character)
|
||||
.findOneBy({ id: characterId });
|
||||
if (!character) {
|
||||
throw npcNotFound();
|
||||
}
|
||||
if (character.currentLocationId !== npc.locationId) {
|
||||
throw npcUnavailable();
|
||||
}
|
||||
|
||||
return npc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the highest-priority dialogue whose conditions hold (spec §11).
|
||||
*
|
||||
* Ties break on key so a content mistake produces the same line every time
|
||||
* rather than whatever the database happened to return first.
|
||||
*/
|
||||
private async resolveDialogue(
|
||||
characterId: string,
|
||||
npc: NpcDefinition,
|
||||
): Promise<DialogueNodeDto | null> {
|
||||
const nodes = await this.dataSource.getRepository(DialogueNode).find({
|
||||
where: { npcId: npc.id, enabled: true },
|
||||
order: { priority: 'DESC', key: 'ASC' },
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
const met = await this.conditions.evaluate(
|
||||
{ characterId, npcId: npc.id },
|
||||
node.conditions,
|
||||
);
|
||||
if (!met) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const responses: DialogueResponseDto[] = [];
|
||||
for (const response of node.responses ?? []) {
|
||||
const allowed = await this.conditions.evaluate(
|
||||
{ characterId, npcId: npc.id },
|
||||
response.conditions,
|
||||
);
|
||||
if (allowed) {
|
||||
responses.push({
|
||||
key: response.key,
|
||||
text: response.text,
|
||||
targetNodeKey: response.targetNodeKey ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { key: node.key, text: node.text, responses };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions the server is willing to honour right now (spec §23).
|
||||
*
|
||||
* Driven by whether the backing data exists and is enabled, not by the
|
||||
* 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[]> {
|
||||
const actions: NpcActionDto[] = [
|
||||
{ type: 'TALK', label: 'Talk', key: null },
|
||||
];
|
||||
|
||||
const shop = await this.dataSource
|
||||
.getRepository(NpcShop)
|
||||
.findOneBy({ npcId: npc.id, enabled: true });
|
||||
if (shop) {
|
||||
actions.push({ type: 'OPEN_SHOP', label: 'Browse Wares', key: shop.key });
|
||||
}
|
||||
|
||||
const profile = await this.findUsableExchangeProfile(npc.id);
|
||||
if (profile) {
|
||||
actions.push({
|
||||
type: 'OPEN_EXCHANGE',
|
||||
label: 'Trade In Goods',
|
||||
key: profile.key,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Markers for the local view. Only backed interactions get one (spec §24). */
|
||||
private async resolveMarkers(npc: NpcDefinition): Promise<NpcMarker[]> {
|
||||
const markers: NpcMarker[] = [];
|
||||
|
||||
const shop = await this.dataSource
|
||||
.getRepository(NpcShop)
|
||||
.findOneBy({ npcId: npc.id, enabled: true });
|
||||
if (shop) {
|
||||
markers.push('MERCHANT');
|
||||
}
|
||||
|
||||
if (await this.findUsableExchangeProfile(npc.id)) {
|
||||
markers.push('EXCHANGE');
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* An enabled exchange profile that actually has an enabled rule.
|
||||
*
|
||||
* An empty profile would otherwise advertise a trade-in screen with nothing
|
||||
* on it.
|
||||
*/
|
||||
private async findUsableExchangeProfile(
|
||||
npcId: string,
|
||||
): Promise<NpcExchangeProfile | null> {
|
||||
const profile = await this.dataSource
|
||||
.getRepository(NpcExchangeProfile)
|
||||
.findOneBy({ npcId, enabled: true });
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ruleCount = await this.dataSource
|
||||
.getRepository(ExchangeRule)
|
||||
.countBy({ profileId: profile.id, enabled: true });
|
||||
|
||||
return ruleCount > 0 ? profile : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that this character has now spoken to this NPC (spec §7).
|
||||
*
|
||||
* `firstMetAt` is written once and never overwritten. The `met` flag it
|
||||
* sets alongside is what a greeting node conditions on, which keeps "have
|
||||
* we met before" in the same FLAG_SET vocabulary as every other gate rather
|
||||
* than inventing a second mechanism for one line of dialogue.
|
||||
*/
|
||||
private async touchNpcState(
|
||||
characterId: string,
|
||||
npcId: string,
|
||||
): Promise<void> {
|
||||
const states = this.dataSource.getRepository(CharacterNpcState);
|
||||
const existing = await states.findOneBy({ characterId, npcId });
|
||||
const now = new Date();
|
||||
|
||||
if (existing) {
|
||||
existing.lastInteractionAt = now;
|
||||
existing.flags = { ...existing.flags, met: true };
|
||||
await states.save(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
await states.save(
|
||||
states.create({
|
||||
characterId,
|
||||
npcId,
|
||||
firstMetAt: now,
|
||||
lastInteractionAt: now,
|
||||
flags: { met: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user