Files
ashen-realms/apps/api/src/npcs/npc.service.ts
Bastian Wagner 081c9f83f9 docs
2026-08-22 16:41:47 +02:00

267 lines
7.9 KiB
TypeScript

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 },
}),
);
}
}