267 lines
7.9 KiB
TypeScript
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 },
|
|
}),
|
|
);
|
|
}
|
|
}
|